diff --git a/Cargo.lock b/Cargo.lock index 04cbd1ab96..8d457df4c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,23 +265,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" dependencies = [ - "event-listener 5.3.1", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.3.1" @@ -343,7 +332,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "event-listener 5.3.1", + "event-listener", "event-listener-strategy", "pin-project-lite", ] @@ -354,14 +343,14 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ - "async-channel 2.3.1", + "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if 1.0.0", - "event-listener 5.3.1", + "event-listener", "futures-lite", "rustix", "tracing", @@ -451,17 +440,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atomic_enum" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6227a8d6fdb862bcb100c4314d0d9579e5cd73fa6df31a2e6f6e1acd3c5f1207" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "atty" version = "0.2.14" @@ -749,7 +727,7 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel 2.3.1", + "async-channel", "async-task", "futures-io", "futures-lite", @@ -1380,12 +1358,12 @@ name = "cloud" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "db", - "ezsockets", + "futures-util", "proto", "reqwest 0.12.9", "tokio", + "tokio-tungstenite", "url", ] @@ -2211,6 +2189,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-positioner", + "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-sql", "tauri-plugin-store", @@ -2553,20 +2532,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" -[[package]] -name = "enfync" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce8c1fbec15a38aced3838c4d7ea90a1403bd50364b5cfe8008e679df0c8dde" -dependencies = [ - "async-trait", - "futures", - "tokio", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasmtimer", -] - [[package]] name = "enum-as-inner" version = "0.6.1" @@ -2669,12 +2634,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.3.1" @@ -2692,36 +2651,10 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" dependencies = [ - "event-listener 5.3.1", + "event-listener", "pin-project-lite", ] -[[package]] -name = "ezsockets" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42421679952f7cbcabe45cc527b3891ff2bc9d60c38995a4b70f27e5c43da811" -dependencies = [ - "async-channel 1.9.0", - "async-trait", - "atomic_enum", - "base64 0.21.7", - "cfg-if 1.0.0", - "enfync", - "futures", - "futures-util", - "getrandom 0.2.15", - "http 0.2.12", - "tokio", - "tokio-tungstenite", - "tokio-tungstenite-wasm", - "tracing", - "tungstenite", - "url", - "wasm-bindgen-futures", - "wasmtimer", -] - [[package]] name = "fancy-regex" version = "0.13.0" @@ -4320,6 +4253,25 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "iterator-sorted" version = "0.1.0" @@ -5833,6 +5785,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.68" @@ -5928,6 +5891,16 @@ dependencies = [ "ureq", ] +[[package]] +name = "os_pipe" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "pango" version = "0.18.3" @@ -6024,6 +5997,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -6474,6 +6453,7 @@ dependencies = [ "bytes", "protobuf", "protobuf-codegen", + "protoc-bin-vendored", ] [[package]] @@ -6528,6 +6508,63 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "protoc-bin-vendored" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd89a830d0eab2502c81a9b8226d446a52998bb78e5e33cb2637c0cdd6068d99" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f563627339f1653ea1453dfbcb4398a7369b768925eb14499457aeaa45afe22c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5025c949a02cd3b60c02501dd0f348c16e8fff464f2a7f27db8a9732c608b746" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9500ce67d132c2f3b572504088712db715755eb9adf69d55641caa2cb68a07" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5462592380cefdc9f1f14635bcce70ba9c91c1c2464c7feb2ce564726614cc41" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c637745681b68b4435484543667a37606c95ddacf15e917710801a0877506030" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38943f3c90319d522f94a6dfd4a134ba5e36148b9506d2d9723a82ebc57c8b55" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc55d7dec32ecaf61e0bd90b3d2392d721a28b95cfd23c3e176eccefbeab2f2" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -7836,6 +7873,16 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_child" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "shlex" version = "0.1.1" @@ -8130,7 +8177,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.3.1", + "event-listener", "futures-channel", "futures-core", "futures-intrusive", @@ -9031,6 +9078,27 @@ dependencies = [ "thiserror 2.0.7", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.7", + "tokio", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.2.0" @@ -9523,9 +9591,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.20.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +checksum = "5c14b3e8ebea4eb2577de77903e6c008d9ac80b5aae1f9ae781c5229ae935a44" dependencies = [ "futures-util", "log", @@ -9533,24 +9601,6 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tokio-tungstenite-wasm" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec8c7cf09b20184f946f114e3d8c0deca34368912c90100812861c14bb63b66" -dependencies = [ - "futures-channel", - "futures-util", - "http 0.2.12", - "httparse", - "js-sys", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "tokio-util" version = "0.7.13" @@ -9696,20 +9746,19 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.20.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +checksum = "d4ab519cc9c1e57e6cab1087f262f9fc978a4e9d5f943b0e029567521d3525cb" dependencies = [ "byteorder", "bytes", "data-encoding", - "http 0.2.12", + "http 1.2.0", "httparse", "log", "rand 0.8.5", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.7", "utf-8", ] @@ -10178,20 +10227,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmtimer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.12.3", - "pin-utils", - "slab", - "wasm-bindgen", -] - [[package]] name = "wayland-backend" version = "0.3.7" @@ -11274,7 +11309,7 @@ dependencies = [ "blocking", "derivative", "enumflags2", - "event-listener 5.3.1", + "event-listener", "futures-core", "futures-sink", "futures-util", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8ba8224ed..85b033e838 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-fs": "^2.0.3", "@tauri-apps/plugin-log": "^2.2.0", + "@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-updater": "^2.0.0", "@tiptap/extension-highlight": "^2.10.3", "@tiptap/extension-placeholder": "^2.10.3", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 77ab6fae19..aac747587b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -57,3 +57,4 @@ flume = "0.11.1" objc = "0.2.7" cap-media = { workspace = true } tauri-plugin-stronghold = "2.2.0" +tauri-plugin-shell = "2.2.0" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b7df9f2ec0..93b86ca2cf 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "sql:default", "sql:allow-execute", "store:default", - "stronghold:default" + "stronghold:default", + "shell:allow-open" ] } diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs new file mode 100644 index 0000000000..8b6e29b1c4 --- /dev/null +++ b/apps/desktop/src-tauri/src/auth.rs @@ -0,0 +1,34 @@ +// https://github.com/CapSoftware/Cap/blob/8671050aaff780f658507579e7d1d75e7ee25d59/apps/desktop/src-tauri/src/auth.rs + +use serde::{Deserialize, Serialize}; +use specta::Type; + +use tauri::{AppHandle, Runtime}; +use tauri_plugin_store::StoreExt; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct AuthStore { + pub token: String, +} + +impl AuthStore { + pub fn load(app: &AppHandle) -> Result, String> { + let Some(store) = app + .store("store") + .map(|s| s.get("auth")) + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + + serde_json::from_value(store).map_err(|e| e.to_string()) + } + + pub fn get(app: &AppHandle) -> Result, String> { + let Some(Some(store)) = app.get_store("store").map(|s| s.get("auth")) else { + return Ok(None); + }; + + serde_json::from_value(store).map_err(|e| e.to_string()) + } +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index c6f7bbc41d..2b39bfdde6 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,8 +1,11 @@ -use crate::{audio, config, permissions}; +use crate::{audio, auth::AuthStore, config::ConfigStore, permissions, App}; +use anyhow::Result; use cap_media::feeds::AudioInputFeed; -use std::path::PathBuf; -use tauri::{AppHandle, Manager}; -use tauri_plugin_store::StoreExt; +use std::{path::PathBuf, sync::Arc}; +use tauri::{AppHandle, Manager, State}; +use tokio::sync::RwLock; + +type MutableState<'a, T> = State<'a, Arc>>; #[tauri::command] #[specta::specta] @@ -68,6 +71,18 @@ pub fn stop_recording() { audio::AppSounds::StopRecording.play(); } +#[tauri::command] +#[specta::specta] +pub async fn auth_url(state: MutableState<'_, App>) -> Result { + let state = state.read().await; + let client = hypr_cloud::Client::new(state.cloud_config.clone()); + + let url = client + .get_authentication_url(hypr_cloud::AuthKind::GoogleOAuth) + .to_string(); + Ok(url) +} + #[tauri::command] #[specta::specta] pub fn list_recordings(app: AppHandle) -> Result, String> { @@ -82,17 +97,16 @@ pub fn list_recordings(app: AppHandle) -> Result, String> #[tauri::command] #[specta::specta] -pub fn set_config(app: AppHandle, config: config::Config) { - let store = app.store("store.json").unwrap(); - store.set("config", serde_json::json!(config)); +pub fn is_authenticated(app: AppHandle) -> bool { + AuthStore::get(&app).is_ok() } -#[tauri::command] -#[specta::specta] -pub fn get_config(app: AppHandle) -> config::Config { - let store = app.store("store.json").unwrap(); - let value = store.get("config").unwrap(); - serde_json::from_value(value).unwrap() +pub enum AuthProvider { + Google, +} + +pub fn login(provider: AuthProvider) -> Result<(), String> { + Ok(()) } fn recordings_path(app: &AppHandle) -> PathBuf { diff --git a/apps/desktop/src-tauri/src/config.rs b/apps/desktop/src-tauri/src/config.rs index 2122b59932..cc91e6fe38 100644 --- a/apps/desktop/src-tauri/src/config.rs +++ b/apps/desktop/src-tauri/src/config.rs @@ -1,25 +1,50 @@ use serde::{Deserialize, Serialize}; use specta::Type; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_store::StoreExt; + #[derive(Debug, Serialize, Deserialize, Type)] #[serde(untagged)] -pub enum Config { +pub enum ConfigStore { V0(ConfigV0), } -#[derive(Debug, Serialize, Deserialize, Type)] -pub struct ConfigV0 { - pub version: u8, - pub language: Language, - pub user_name: String, +impl ConfigStore { + pub fn load(app: &AppHandle) -> Result, String> { + let Some(store) = app + .store("store") + .map(|s: std::sync::Arc>| s.get("config")) + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + + serde_json::from_value(store).map_err(|e| e.to_string()) + } + + pub fn get(app: &AppHandle) -> Result, String> { + let Some(Some(store)) = app.get_store("store").map(|s| s.get("config")) else { + return Ok(None); + }; + + serde_json::from_value(store).map_err(|e| e.to_string()) + } } -impl Default for Config { +impl Default for ConfigStore { fn default() -> Self { Self::V0(ConfigV0::default()) } } +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct ConfigV0 { + pub version: u8, + pub language: Language, + pub user_name: String, +} + impl Default for ConfigV0 { fn default() -> Self { Self { @@ -48,9 +73,9 @@ mod tests { #[test] fn test_default_config() { - let config = Config::default(); + let config = ConfigStore::default(); match config { - Config::V0(cfg_v0) => { + ConfigStore::V0(cfg_v0) => { assert_eq!(cfg_v0.version, 0); } } diff --git a/apps/desktop/src-tauri/src/events.rs b/apps/desktop/src-tauri/src/events.rs index 8d8e92e4f6..2972ffdca8 100644 --- a/apps/desktop/src-tauri/src/events.rs +++ b/apps/desktop/src-tauri/src/events.rs @@ -1,5 +1,12 @@ +use serde::{Deserialize, Serialize}; use specta::Type; use tauri_specta::Event; -#[derive(Type, Event)] -pub struct Transcript {} +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +pub struct Transcript; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +pub struct NotAuthenticated; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +pub struct JustAuthenticated; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 02755f692a..38cecc6a88 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,12 @@ use cap_media::feeds::{AudioInputFeed, AudioInputSamplesSender}; -use tauri::{AppHandle, Manager}; use tokio::sync::RwLock; +use tauri::{AppHandle, Manager}; +use tauri_plugin_deep_link::DeepLinkExt; +use tauri_specta::Event; + mod audio; +mod auth; mod commands; mod config; mod db; @@ -10,23 +14,17 @@ mod events; mod permissions; mod session; -#[derive(specta::Type)] -#[serde(rename_all = "camelCase")] pub struct App { - #[serde(skip)] handle: AppHandle, - #[serde(skip)] audio_input_feed: Option, - #[serde(skip)] audio_input_tx: AudioInputSamplesSender, + cloud_config: hypr_cloud::ClientConfig, } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let specta_builder = tauri_specta::Builder::new() .commands(tauri_specta::collect_commands![ - commands::set_config, - commands::get_config, commands::list_audio_devices, commands::start_recording, commands::stop_recording, @@ -35,8 +33,13 @@ pub fn run() { commands::list_apple_calendars, commands::list_apple_events, permissions::open_permission_settings, + commands::auth_url, + ]) + .events(tauri_specta::collect_events![ + events::Transcript, + events::NotAuthenticated, + events::JustAuthenticated, ]) - .events(tauri_specta::collect_events![events::Transcript]) .typ::() .typ::() .error_handling(tauri_specta::ErrorHandlingMode::Throw); @@ -84,14 +87,36 @@ pub fn run() { ); } - builder = builder.plugin(tauri_plugin_deep_link::init()); + builder = builder.plugin(tauri_plugin_deep_link::init()).setup(|app| { + let app_handle = app.handle().clone(); + + app.deep_link().on_open_url(move |event| { + let urls = event.urls(); + let url = urls.first().unwrap(); + + if url.path() == "/auth" { + let query_pairs: std::collections::HashMap = url + .query_pairs() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let local_data_dir = app_handle.path().app_local_data_dir().unwrap(); + let file_path = local_data_dir.join("api_key.txt"); + let key = query_pairs.get("key").unwrap().clone(); + + std::fs::write(&file_path, key).unwrap(); + let _ = events::JustAuthenticated.emit(&app_handle); + } + }); + + Ok(()) + }); // https://v2.tauri.app/plugin/deep-linking/#registering-desktop-deep-links-at-runtime #[cfg(any(windows, target_os = "linux"))] { builder = builder.setup(|app| { { - use tauri_plugin_deep_link::DeepLinkExt; app.deep_link().register_all()?; } @@ -104,10 +129,36 @@ pub fn run() { builder // TODO: https://v2.tauri.app/plugin/updater/#building // .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_shell::init()) .invoke_handler({ let handler = specta_builder.invoke_handler(); move |invoke| handler(invoke) }) + .setup(move |app| { + let app = app.handle().clone(); + + let mut cloud_config = hypr_cloud::ClientConfig { + base_url: if cfg!(debug_assertions) { + "http://localhost:4000".parse().unwrap() + } else { + "https://server.hyprnote.com".parse().unwrap() + }, + auth_token: None, + }; + + if let Ok(Some(auth)) = auth::AuthStore::load(&app) { + cloud_config.auth_token = Some(auth.token); + } + + app.manage(RwLock::new(App { + handle: app.clone(), + audio_input_tx, + audio_input_feed: None, + cloud_config, + })); + + Ok(()) + }) .setup(|app| { let salt_path = app.path().app_local_data_dir()?.join("salt.txt"); app.handle() @@ -141,17 +192,6 @@ pub fn run() { } Ok(()) }) - .setup(move |app| { - let app = app.handle().clone(); - - app.manage(RwLock::new(App { - handle: app.clone(), - audio_input_tx, - audio_input_feed: None, - })); - - Ok(()) - }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/desktop/src/pages/Home.tsx b/apps/desktop/src/pages/Home.tsx index 82cd580fbb..557c43dbcc 100644 --- a/apps/desktop/src/pages/Home.tsx +++ b/apps/desktop/src/pages/Home.tsx @@ -4,7 +4,9 @@ import { mockNotes } from "../mocks/data"; import { UpcomingEvents } from "../components/home/UpcomingEvents"; import { PastNotes } from "../components/home/PastNotes"; import { NewUserBanner } from "../components/home/NewUserBanner"; -import { invoke } from "@tauri-apps/api/core"; + +import { open } from "@tauri-apps/plugin-shell"; +import { commands } from "../types"; export default function Home() { const [isNewUser] = useState(true); @@ -54,12 +56,13 @@ export default function Home() {
{isNewUser && } diff --git a/apps/desktop/src/types/tauri.ts b/apps/desktop/src/types/tauri.ts index 5159462b0b..a64a1e7477 100644 --- a/apps/desktop/src/types/tauri.ts +++ b/apps/desktop/src/types/tauri.ts @@ -5,12 +5,6 @@ /** user-defined commands **/ export const commands = { - async setConfig(config: Config): Promise { - await TAURI_INVOKE("set_config", { config }); - }, - async getConfig(): Promise { - return await TAURI_INVOKE("get_config"); - }, async listAudioDevices(): Promise { return await TAURI_INVOKE("list_audio_devices"); }, @@ -35,13 +29,20 @@ export const commands = { async openPermissionSettings(permission: OSPermission): Promise { await TAURI_INVOKE("open_permission_settings", { permission }); }, + async authUrl(): Promise { + return await TAURI_INVOKE("auth_url"); + }, }; /** user-defined events **/ export const events = __makeEvents__<{ + justAuthenticated: JustAuthenticated; + notAuthenticated: NotAuthenticated; transcript: Transcript; }>({ + justAuthenticated: "just-authenticated", + notAuthenticated: "not-authenticated", transcript: "transcript", }); @@ -50,24 +51,19 @@ export const events = __makeEvents__<{ /** user-defined types **/ export type Calendar = { title: string }; -export type Config = ConfigV0; -export type ConfigV0 = { - version: number; - language: Language; - user_name: string; -}; export type Event = { title: string; start_date: string; end_date: string }; export type EventFilter = { last_n_days: number | null; calendar_titles: string[]; }; -export type Language = "English" | "Korean"; +export type JustAuthenticated = null; +export type NotAuthenticated = null; export type OSPermission = | "screenRecording" | "camera" | "microphone" | "accessibility"; -export type Transcript = Record; +export type Transcript = null; /** tauri-specta globals **/ diff --git a/crates/cloud/Cargo.toml b/crates/cloud/Cargo.toml index 47cf6eda68..0d1a6dc347 100644 --- a/crates/cloud/Cargo.toml +++ b/crates/cloud/Cargo.toml @@ -12,5 +12,5 @@ tokio = { workspace = true } url = "2.5.4" reqwest = { version = "0.12.9", features = ["json"] } -ezsockets = { version = "0.6.4", features = ["native_client"] } -async-trait = "0.1.83" +tokio-tungstenite = "0.26.0" +futures-util = "0.3.31" diff --git a/crates/cloud/src/lib.rs b/crates/cloud/src/lib.rs index 712ea560e5..fd4b149edf 100644 --- a/crates/cloud/src/lib.rs +++ b/crates/cloud/src/lib.rs @@ -1,9 +1,11 @@ use anyhow::Result; -use async_trait::async_trait; -use tokio::sync::mpsc; use url::Url; -use hypr_proto::protobuf::Message; +use futures_util::StreamExt; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; + +// use hypr_proto::protobuf::Message; use hypr_proto::v0 as proto; pub type TranscribeInputSender = mpsc::Sender; @@ -16,6 +18,12 @@ pub struct TranscribeHandler { output_receiver: TranscribeOutputReceiver, } +// TODO: we are splitting, for concurrent usage & send/receive in multiple threads. +// it makes no sense if we combine this two. +// we should return 2 struct, I think. + +// TODO: periodical ping is needed. + impl TranscribeHandler { pub async fn tx(&self, value: proto::TranscribeInputChunk) -> Result<()> { self.input_sender @@ -29,38 +37,20 @@ impl TranscribeHandler { } } -struct WebsocketClient { - output_sender: TranscribeOutputSender, -} - -#[async_trait] -impl ezsockets::ClientExt for WebsocketClient { - // https://docs.rs/ezsockets/latest/ezsockets/client/trait.ClientExt.html - type Call = (); - - async fn on_text(&mut self, _text: String) -> Result<(), ezsockets::Error> { - Ok(()) - } - - async fn on_binary(&mut self, bytes: Vec) -> Result<(), ezsockets::Error> { - let data = proto::TranscribeOutputChunk::parse_from_bytes(&bytes).unwrap(); - let _ = self.output_sender.send(data).await; - Ok(()) - } - - async fn on_call(&mut self, _call: Self::Call) -> Result<(), ezsockets::Error> { - Ok(()) - } -} - pub struct Client { config: ClientConfig, reqwest_client: reqwest::Client, - ws_client: Option>, } +#[derive(Debug, Clone)] pub struct ClientConfig { - base_url: Url, + pub base_url: Url, + pub auth_token: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AuthKind { + GoogleOAuth, } impl Client { @@ -70,43 +60,35 @@ impl Client { Self { config, reqwest_client: client, - ws_client: None, } } - fn enhance_url(&self) -> Url { - let mut url = self.config.base_url.clone(); - url.set_path("/enhance"); - url - } - - fn ws_url(&self) -> Url { - let mut url = self.config.base_url.clone(); - - if self.config.base_url.scheme() == "http" { - url.set_scheme("ws").unwrap(); - } else { - url.set_scheme("wss").unwrap(); + pub fn get_authentication_url(&self, kind: AuthKind) -> Url { + match kind { + AuthKind::GoogleOAuth => { + let mut url = self.config.base_url.clone(); + url.set_path("/auth/desktop/login/google"); + url + } } - - url } pub async fn ws_connect(&mut self) -> Result { + if self.config.auth_token.is_none() { + anyhow::bail!("No auth token provided"); + } + let (input_sender, mut input_receiver) = mpsc::channel::(100); let (output_sender, output_receiver) = mpsc::channel::(100); - let config = ezsockets::ClientConfig::new(self.ws_url().as_str()); + let mut request = self.ws_url().to_string().into_client_request().unwrap(); + request.headers_mut().insert( + "x-hypr-token", + self.config.auth_token.clone().unwrap().parse().unwrap(), + ); - let (handle, future) = - ezsockets::connect(|_client| WebsocketClient { output_sender }, config).await; - - tokio::spawn(async move { - while let Some(input) = input_receiver.recv().await { - let _ = handle.binary(input.audio); - } - future.await.unwrap(); - }); + let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?; + let (write, read) = ws_stream.split(); Ok(TranscribeHandler { input_sender, @@ -115,7 +97,6 @@ impl Client { } pub fn ws_disconnect(&mut self) -> Result<()> { - self.ws_client.take().unwrap().close(None)?; Ok(()) } @@ -129,6 +110,24 @@ impl Client { Ok(()) } + + fn enhance_url(&self) -> Url { + let mut url = self.config.base_url.clone(); + url.set_path("/enhance"); + url + } + + fn ws_url(&self) -> Url { + let mut url = self.config.base_url.clone(); + + if self.config.base_url.scheme() == "http" { + url.set_scheme("ws").unwrap(); + } else { + url.set_scheme("wss").unwrap(); + } + + url + } } #[cfg(test)] @@ -139,6 +138,7 @@ mod tests { async fn test_simple() { let _ = Client::new(ClientConfig { base_url: Url::parse("http://localhost:8080").unwrap(), + auth_token: Some("".to_string()), }); } } diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 4cf837173d..67b92ea969 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -4,9 +4,10 @@ version = "0.1.0" edition = "2021" [build-dependencies] -protobuf-codegen = "3.7.1" protobuf = "3.7.1" +protobuf-codegen = "3.7.1" +protoc-bin-vendored = "3.1.0" [dependencies] -protobuf = { version = "3.7.1", features = ["bytes"] } bytes = "1.9.0" +protobuf = { version = "3.7.1", features = ["bytes"] } diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 3eba595fdb..fbdb9579e5 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -1,7 +1,6 @@ use protobuf::descriptor::field_descriptor_proto::Type; use protobuf::reflect::FieldDescriptor; use protobuf_codegen::{Codegen, Customize, CustomizeCallback}; - struct BytesConverter; impl CustomizeCallback for BytesConverter { @@ -18,6 +17,7 @@ fn main() { println!("cargo:rerun-if-changed=../../packages/proto/v0.proto"); Codegen::new() + .protoc_path(&protoc_bin_vendored::protoc_bin_path().unwrap()) .out_dir("src/generated") .include("../../packages/proto") .input("../../packages/proto/v0.proto") diff --git a/crates/proto/src/generated/v0.rs b/crates/proto/src/generated/v0.rs index 27510d2da5..9b1b4b6413 100644 --- a/crates/proto/src/generated/v0.rs +++ b/crates/proto/src/generated/v0.rs @@ -1,5 +1,5 @@ // This file is generated by rust-protobuf 3.7.1. Do not edit -// .proto file is parsed by protoc 25.2 +// .proto file is parsed by protoc 28.2 // @generated // https://github.com/rust-lang/rust-clippy/issues/702 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 948b06a96b..ce89b3072b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@tauri-apps/plugin-log': specifier: ^2.2.0 version: 2.2.0 + '@tauri-apps/plugin-shell': + specifier: ^2.2.0 + version: 2.2.0 '@tauri-apps/plugin-updater': specifier: ^2.0.0 version: 2.3.0 @@ -1065,6 +1068,9 @@ packages: '@tauri-apps/plugin-log@2.2.0': resolution: {integrity: sha512-g6CsQAR1lsm5ABSZZxpM/iCn86GrMDTTlhj7GPkZkYBRSm3+WczfOAl7SV7HDn77tOKCzhZffwI5uHfRoHutrw==} + '@tauri-apps/plugin-shell@2.2.0': + resolution: {integrity: sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==} + '@tauri-apps/plugin-updater@2.3.0': resolution: {integrity: sha512-qdzyZEUN69FZQ/nRx51fBub10tT6wffJl3DLVo9q922Gvw8Wk++rZhoD9eethPlZYbog/7RGgT8JkrfLh5BKAg==} @@ -2972,6 +2978,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.1.1 + '@tauri-apps/plugin-shell@2.2.0': + dependencies: + '@tauri-apps/api': 2.1.1 + '@tauri-apps/plugin-updater@2.3.0': dependencies: '@tauri-apps/api': 2.1.1 diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index 61a73933c8..0000000000 --- a/server/.dockerignore +++ /dev/null @@ -1,45 +0,0 @@ -# This file excludes paths from the Docker build context. -# -# By default, Docker's build context includes all files (and folders) in the -# current directory. Even if a file isn't copied into the container it is still sent to -# the Docker daemon. -# -# There are multiple reasons to exclude files from the build context: -# -# 1. Prevent nested folders from being copied into the container (ex: exclude -# /assets/node_modules when copying /assets) -# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) -# 3. Avoid sending files containing sensitive information -# -# More information on using .dockerignore is available here: -# https://docs.docker.com/engine/reference/builder/#dockerignore-file - -.dockerignore - -# Ignore git, but keep git HEAD and refs to access current commit hash if needed: -# -# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat -# d0b8727759e1e0e7aa3d41707d12376e373d5ecc -.git -!.git/HEAD -!.git/refs - -# Common development/test artifacts -/cover/ -/doc/ -/test/ -/tmp/ -.elixir_ls - -# Mix artifacts -/_build/ -/deps/ -*.ez - -# Generated on crash by the VM -erl_crash.dump - -# Static artifacts - These should be fetched and built inside the Docker image -/assets/node_modules/ -/priv/static/assets/ -/priv/static/cache_manifest.json diff --git a/server/.formatter.exs b/server/.formatter.exs index 5971023f6b..ef8840ce6f 100644 --- a/server/.formatter.exs +++ b/server/.formatter.exs @@ -1,5 +1,6 @@ [ import_deps: [:ecto, :ecto_sql, :phoenix], subdirectories: ["priv/*/migrations"], - inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/server/.gitignore b/server/.gitignore index 3faae0191a..765fb92303 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -25,3 +25,13 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). hypr-*.tar +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 764a8535b4..0000000000 --- a/server/Dockerfile +++ /dev/null @@ -1,92 +0,0 @@ -# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian -# instead of Alpine to avoid DNS resolution issues in production. -# -# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu -# https://hub.docker.com/_/ubuntu?tab=tags -# -# This file is based on these images: -# -# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image -# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240904-slim - for the release image -# - https://pkgs.org/ - resource for finding needed packages -# - Ex: hexpm/elixir:1.17.2-erlang-27.0.1-debian-bullseye-20240904-slim -# -ARG ELIXIR_VERSION=1.17.2 -ARG OTP_VERSION=27.0.1 -ARG DEBIAN_VERSION=bullseye-20240904-slim - -ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" -ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" - -FROM ${BUILDER_IMAGE} as builder - -# install build dependencies -RUN apt-get update -y && apt-get install -y build-essential git \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# prepare build dir -WORKDIR /app - -# install hex + rebar -RUN mix local.hex --force && \ - mix local.rebar --force - -# set build ENV -ENV MIX_ENV="prod" - -# install mix dependencies -COPY mix.exs mix.lock ./ -RUN mix deps.get --only $MIX_ENV -RUN mkdir config - -# copy compile-time config files before we compile dependencies -# to ensure any relevant config change will trigger the dependencies -# to be re-compiled. -COPY config/config.exs config/${MIX_ENV}.exs config/ -RUN mix deps.compile - -COPY priv priv - -COPY lib lib - -# Compile the release -RUN mix compile - -# Changes to config/runtime.exs don't require recompiling the code -COPY config/runtime.exs config/ - -COPY rel rel -RUN mix release - -# start a new build stage so that the final image will only contain -# the compiled release and other runtime necessities -FROM ${RUNNER_IMAGE} - -RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# Set the locale -RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen - -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 - -WORKDIR "/app" -RUN chown nobody /app - -# set runner ENV -ENV MIX_ENV="prod" - -# Only copy the final release from the build stage -COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/hypr ./ - -USER nobody - -# If using an environment that doesn't automatically reap zombie processes, it is -# advised to add an init process such as tini via `apt-get install` -# above and adding an entrypoint. See https://github.com/krallin/tini for details -# ENTRYPOINT ["/tini", "--"] - -CMD ["/app/bin/server"] diff --git a/server/README.md b/server/README.md index 76ac08ddc6..8f2515c3e8 100644 --- a/server/README.md +++ b/server/README.md @@ -1 +1,18 @@ -# Server +# Hypr + +To start your Phoenix server: + + * Run `mix setup` to install and setup dependencies + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/server/assets/css/app.css b/server/assets/css/app.css new file mode 100644 index 0000000000..378c8f9056 --- /dev/null +++ b/server/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/server/assets/js/app.js b/server/assets/js/app.js new file mode 100644 index 0000000000..d5e278afe5 --- /dev/null +++ b/server/assets/js/app.js @@ -0,0 +1,44 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/server/assets/tailwind.config.js b/server/assets/tailwind.config.js new file mode 100644 index 0000000000..5365a303b8 --- /dev/null +++ b/server/assets/tailwind.config.js @@ -0,0 +1,74 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/hypr_web.ex", + "../lib/hypr_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/server/assets/vendor/topbar.js b/server/assets/vendor/topbar.js new file mode 100644 index 0000000000..41957274d7 --- /dev/null +++ b/server/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/server/config/config.exs b/server/config/config.exs index a1c051b06a..8f3410250e 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -16,11 +16,42 @@ config :hypr, HyprWeb.Endpoint, url: [host: "localhost"], adapter: Bandit.PhoenixAdapter, render_errors: [ - formats: [json: HyprWeb.ErrorJSON], + formats: [html: HyprWeb.ErrorHTML, json: HyprWeb.ErrorJSON], layout: false ], pubsub_server: Hypr.PubSub, - live_view: [signing_salt: "FGplErUX"] + live_view: [signing_salt: "hhkuEfA8"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :hypr, Hypr.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + hypr: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + hypr: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] # Configures Elixir's Logger config :logger, :console, diff --git a/server/config/dev.exs b/server/config/dev.exs index 730760b637..8b4e13c407 100644 --- a/server/config/dev.exs +++ b/server/config/dev.exs @@ -23,8 +23,11 @@ config :hypr, HyprWeb.Endpoint, check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "ayHrC/h6i0LMY7jpChPNqljJLFdb6KFs5AX6jWChWmtQdtUJ0ex58+6rC4JRHtDa", - watchers: [] + secret_key_base: "tCazuLUp2tzcSYGQ/bl9TK9UOYr8VdHhZJYzw2KDHj7U6dAGn+6ZvbLLYKZsKRKA", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:hypr, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:hypr, ~w(--watch)]} + ] # ## SSL Support # @@ -49,6 +52,16 @@ config :hypr, HyprWeb.Endpoint, # configured to run both http and https servers on # different ports. +# Watch static and templates for browser reloading. +config :hypr, HyprWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/hypr_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + # Enable dev routes for dashboard and mailbox config :hypr, dev_routes: true @@ -61,3 +74,12 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/server/config/prod.exs b/server/config/prod.exs index 1fe2d9e854..3376fe9b7c 100644 --- a/server/config/prod.exs +++ b/server/config/prod.exs @@ -1,5 +1,18 @@ import Config +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :hypr, HyprWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Hypr.Finch + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + # Do not print debug messages in production config :logger, level: :info diff --git a/server/config/runtime.exs b/server/config/runtime.exs index 3dc67f1718..c78ade877c 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -28,6 +28,8 @@ if config_env() == :prod do For example: ecto://USER:PASS@HOST/DATABASE """ + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + config :hypr, Hypr.Repo, url: database_url, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), @@ -94,4 +96,27 @@ if config_env() == :prod do # force_ssl: [hsts: true] # # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :hypr, Hypr.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end + +config :stytch, + public_token: "public-token-test-55c89e10-a4c2-4cb7-8bf2-fa1c667e49cd", + project_id: "project-test-045f680c-d466-4a1c-92d8-14dca2d061e3", + secret: "secret-test-LMar2VbT0spmQYivIG3Y5sgugiO3J79YcZI=" diff --git a/server/config/test.exs b/server/config/test.exs index d990333101..68cebe36d3 100644 --- a/server/config/test.exs +++ b/server/config/test.exs @@ -17,11 +17,21 @@ config :hypr, Hypr.Repo, # you can enable the server option below. config :hypr, HyprWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "nbBYr1GEmPnhQsSxJMiN/It9IZ8kDg4mFiU41Q268fnRlAV3BZld7XU5AA6gfbNz", + secret_key_base: "LtJItTgjUPpx9eCrxP7JFNBRSztQSvcxKGDi92cshWq3R0twWqNFCjkvyjr/q2Xi", server: false +# In test we don't send emails +config :hypr, Hypr.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + # Print only warnings and errors during test config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/server/fly.toml b/server/fly.toml deleted file mode 100644 index c925b6350b..0000000000 --- a/server/fly.toml +++ /dev/null @@ -1,42 +0,0 @@ -# fly.toml app configuration file generated for hypr-server on 2024-12-16T14:14:08+09:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'hypr-server' -primary_region = 'sjc' -kill_signal = 'SIGTERM' - -[build] - -[deploy] - release_command = '/app/bin/migrate' - -[env] - PHX_HOST = 'server.hyprnote.com' - PORT = '8080' - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = 'stop' - auto_start_machines = true - min_machines_running = 1 - processes = ['app'] - - [http_service.concurrency] - type = 'connections' - hard_limit = 1000 - soft_limit = 1000 - -[[http_service.checks]] - grace_period = "10s" - interval = "30s" - method = "GET" - path = "/health" - timeout = "5s" - -[[vm]] - memory = '1gb' - cpu_kind = 'shared' - cpus = 1 diff --git a/server/lib/hypr/application.ex b/server/lib/hypr/application.ex index b9c1091317..4d9fdaae4e 100644 --- a/server/lib/hypr/application.ex +++ b/server/lib/hypr/application.ex @@ -12,7 +12,9 @@ defmodule Hypr.Application do HyprWeb.Telemetry, Hypr.Repo, {DNSCluster, query: Application.get_env(:hypr, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: Hypr.PubSub} + {Phoenix.PubSub, name: Hypr.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: Hypr.Finch} ] ++ stripe() ++ [HyprWeb.Endpoint] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/server/lib/hypr/mailer.ex b/server/lib/hypr/mailer.ex new file mode 100644 index 0000000000..ecc9332d35 --- /dev/null +++ b/server/lib/hypr/mailer.ex @@ -0,0 +1,3 @@ +defmodule Hypr.Mailer do + use Swoosh.Mailer, otp_app: :hypr +end diff --git a/server/lib/hypr/release.ex b/server/lib/hypr/release.ex deleted file mode 100644 index 9edb19e56e..0000000000 --- a/server/lib/hypr/release.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Hypr.Release do - @moduledoc """ - Used for executing DB release tasks when run in production without Mix - installed. - """ - @app :hypr - - def migrate do - load_app() - - for repo <- repos() do - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) - end - end - - def rollback(repo, version) do - load_app() - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) - end - - defp repos do - Application.fetch_env!(@app, :ecto_repos) - end - - defp load_app do - Application.load(@app) - end -end diff --git a/server/lib/hypr_web.ex b/server/lib/hypr_web.ex index 1b4a39a5c2..db018f8698 100644 --- a/server/lib/hypr_web.ex +++ b/server/lib/hypr_web.ex @@ -26,6 +26,7 @@ defmodule HyprWeb do # Import common connection and controller functions to use in pipelines import Plug.Conn import Phoenix.Controller + import Phoenix.LiveView.Router end end @@ -42,11 +43,58 @@ defmodule HyprWeb do layouts: [html: HyprWeb.Layouts] import Plug.Conn + import HyprWeb.Gettext unquote(verified_routes()) end end + def live_view do + quote do + use Phoenix.LiveView, + layout: {HyprWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import HyprWeb.CoreComponents + import HyprWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + def verified_routes do quote do use Phoenix.VerifiedRoutes, diff --git a/server/lib/hypr_web/components/core_components.ex b/server/lib/hypr_web/components/core_components.ex new file mode 100644 index 0000000000..7f9eb2719e --- /dev/null +++ b/server/lib/hypr_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule HyprWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + use Gettext, backend: HyprWeb.Gettext + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+
+ + + {render_slot(col, @row_item.(row))} + +
+
+
+ + + {render_slot(action, @row_item.(row))} + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title"><%= @post.title %> + <:item title="Views"><%= @post.views %> + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
{item.title}
+
{render_slot(item)}
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + {render_slot(@inner_block)} + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(HyprWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(HyprWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/server/lib/hypr_web/components/layouts.ex b/server/lib/hypr_web/components/layouts.ex new file mode 100644 index 0000000000..064d8bb917 --- /dev/null +++ b/server/lib/hypr_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule HyprWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use HyprWeb, :controller` and + `use HyprWeb, :live_view`. + """ + use HyprWeb, :html + + embed_templates "layouts/*" +end diff --git a/server/lib/hypr_web/components/layouts/app.html.heex b/server/lib/hypr_web/components/layouts/app.html.heex new file mode 100644 index 0000000000..3b3b607454 --- /dev/null +++ b/server/lib/hypr_web/components/layouts/app.html.heex @@ -0,0 +1,32 @@ +
+
+
+ + + +

+ v{Application.spec(:phoenix, :vsn)} +

+
+ +
+
+
+
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
diff --git a/server/lib/hypr_web/components/layouts/root.html.heex b/server/lib/hypr_web/components/layouts/root.html.heex new file mode 100644 index 0000000000..c149fdf993 --- /dev/null +++ b/server/lib/hypr_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title suffix=" · Phoenix Framework"> + {assigns[:page_title] || "Hypr"} + + + + + + {@inner_content} + + diff --git a/server/lib/hypr_web/controllers/auth_controller.ex b/server/lib/hypr_web/controllers/auth_controller.ex new file mode 100644 index 0000000000..e623d80d71 --- /dev/null +++ b/server/lib/hypr_web/controllers/auth_controller.ex @@ -0,0 +1,44 @@ +defmodule HyprWeb.AuthController do + use HyprWeb, :controller + + def desktop_login_google(conn, %{"id" => id}) do + conn + |> redirect(external: ~p"/auth/web/login/google?id=#{id}") + end + + def web_login_google(conn, params) do + # https://stytch.com/docs/workspace-management/redirect-urls#user-app-state-example + redirect_url = + case params["id"] do + nil -> HyprWeb.Endpoint.url() <> ~p"/auth/web/callback" + id -> HyprWeb.Endpoint.url() <> ~p"/auth/web/callback?id=#{id}" + end + + # https://stytch.com/docs/api/oauth-google-start + url = + Stytch.start_oauth_url( + "google", + Application.fetch_env!(:stytch, :public_token), + # custom_scopes: "email profile https://www.googleapis.com/auth/calendar", + login_redirect_url: redirect_url, + signup_redirect_url: redirect_url + ) + + redirect(conn, external: url) + end + + def web_logout(conn, _params) do + conn + |> delete_session(:stytch_session_token) + |> redirect(to: ~p"/") + end + + def web_callback(conn, %{"stytch_token_type" => "oauth", "token" => token}) do + {:ok, %{session: %{stytch_session: %{session_token: session_token}}, user: _user}} = + Stytch.authenticate_oauth(token, %{session_duration_minutes: 60}) + + conn + |> put_session(:stytch_session_token, session_token) + |> redirect(to: ~p"/") + end +end diff --git a/server/lib/hypr_web/controllers/error_html.ex b/server/lib/hypr_web/controllers/error_html.ex new file mode 100644 index 0000000000..82d7f97acf --- /dev/null +++ b/server/lib/hypr_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule HyprWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use HyprWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/hypr_web/controllers/error_html/404.html.heex + # * lib/hypr_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/server/lib/hypr_web/controllers/page_controller.ex b/server/lib/hypr_web/controllers/page_controller.ex new file mode 100644 index 0000000000..e19919ad65 --- /dev/null +++ b/server/lib/hypr_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule HyprWeb.PageController do + use HyprWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/server/lib/hypr_web/controllers/page_html.ex b/server/lib/hypr_web/controllers/page_html.ex new file mode 100644 index 0000000000..21933b416d --- /dev/null +++ b/server/lib/hypr_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule HyprWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use HyprWeb, :html + + embed_templates "page_html/*" +end diff --git a/server/lib/hypr_web/controllers/page_html/home.html.heex b/server/lib/hypr_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000000..b590643594 --- /dev/null +++ b/server/lib/hypr_web/controllers/page_html/home.html.heex @@ -0,0 +1,2 @@ +<.flash_group flash={@flash} /> +

Home

diff --git a/server/lib/hypr_web/endpoint.ex b/server/lib/hypr_web/endpoint.ex index e936cc9413..7e7a9c2671 100644 --- a/server/lib/hypr_web/endpoint.ex +++ b/server/lib/hypr_web/endpoint.ex @@ -7,19 +7,19 @@ defmodule HyprWeb.Endpoint do @session_options [ store: :cookie, key: "_hypr_key", - signing_salt: "Z/RHYM4E", + signing_salt: "MWJdmtgb", same_site: "Lax" ] - plug HyprWeb.Plugs.Health + plug HyprWeb.HealthPlug - socket "/v0/conversation", HyprWeb.Session, - websocket: [path: "/"], + socket "/v0/session", HyprWeb.Session, + websocket: [path: "/", connect_info: [:uri, :x_headers]], longpoll: false - # socket "/live", Phoenix.LiveView.Socket, - # websocket: [connect_info: [session: @session_options]], - # longpoll: [connect_info: [session: @session_options]] + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # @@ -34,6 +34,8 @@ defmodule HyprWeb.Endpoint do # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader plug Phoenix.CodeReloader plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hypr end diff --git a/server/lib/hypr_web/gettext.ex b/server/lib/hypr_web/gettext.ex new file mode 100644 index 0000000000..6451ac4837 --- /dev/null +++ b/server/lib/hypr_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule HyprWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import HyprWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :hypr +end diff --git a/server/lib/hypr_web/plugs/auth_plug.ex b/server/lib/hypr_web/plugs/auth_plug.ex new file mode 100644 index 0000000000..eb76086b53 --- /dev/null +++ b/server/lib/hypr_web/plugs/auth_plug.ex @@ -0,0 +1,28 @@ +defmodule HyprWeb.AuthPlug do + import Plug.Conn + import Phoenix.Controller, only: [redirect: 2] + + use Phoenix.VerifiedRoutes, endpoint: HyprWeb.Endpoint, router: HyprWeb.Router + + def init(opts), do: opts + + def call(conn, _opts) do + token = get_session(conn, :stytch_session_token) + + if token == nil do + redirect_to_login(conn) + else + case Stytch.authenticate_session(token, %{}) do + {:ok, %{session: _session}} -> conn + {:error, _} -> redirect_to_login(conn) + end + end + end + + defp redirect_to_login(conn) do + conn + |> delete_session(:stytch_session_token) + |> redirect(to: ~p"/auth/web/login/google") + |> halt() + end +end diff --git a/server/lib/hypr_web/plugs/health.ex b/server/lib/hypr_web/plugs/health_plug.ex similarity index 93% rename from server/lib/hypr_web/plugs/health.ex rename to server/lib/hypr_web/plugs/health_plug.ex index 8aa5df9d82..aea055307f 100644 --- a/server/lib/hypr_web/plugs/health.ex +++ b/server/lib/hypr_web/plugs/health_plug.ex @@ -1,4 +1,4 @@ -defmodule HyprWeb.Plugs.Health do +defmodule HyprWeb.HealthPlug do import Plug.Conn def init(opts), do: opts diff --git a/server/lib/hypr_web/router.ex b/server/lib/hypr_web/router.ex index 4223f5063c..7e09275023 100644 --- a/server/lib/hypr_web/router.ex +++ b/server/lib/hypr_web/router.ex @@ -1,11 +1,48 @@ defmodule HyprWeb.Router do use HyprWeb, :router + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {HyprWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :browser_unauthenticated do + plug :browser + end + + pipeline :browser_authenticated do + plug :browser + plug HyprWeb.AuthPlug + end + pipeline :api do plug :accepts, ["json"] end - scope "/api", HyprWeb do - pipe_through :api + scope "/", HyprWeb do + pipe_through :browser_authenticated + + get "/", PageController, :home + end + + scope "/auth", HyprWeb do + pipe_through :browser_unauthenticated + + get "/desktop/login/google", AuthController, :desktop_login_google + get "/web/login/google", AuthController, :web_login_google + get "/web/logout", AuthController, :web_logout + get "/web/callback", AuthController, :web_callback + end + + if Application.compile_env(:hypr, :dev_routes) do + scope "/dev" do + pipe_through :browser + + forward "/mailbox", Plug.Swoosh.MailboxPreview + end end end diff --git a/server/lib/hypr_web/session.ex b/server/lib/hypr_web/session.ex index 650f66a74a..0cb5d25768 100644 --- a/server/lib/hypr_web/session.ex +++ b/server/lib/hypr_web/session.ex @@ -5,7 +5,10 @@ defmodule HyprWeb.Session do def child_spec(_opts), do: :ignore @impl Phoenix.Socket.Transport - def connect(state), do: {:ok, state} + def connect(transport_info) do + IO.inspect(transport_info) + {:ok, %{}} + end @impl Phoenix.Socket.Transport def init(state) do @@ -26,7 +29,7 @@ defmodule HyprWeb.Session do end @impl Phoenix.Socket.Transport - def handle_info({:stt, {:transcript, text}}, state) do + def handle_info({:stt, {:transcript, _text}}, state) do {:ok, state} end diff --git a/server/mix.exs b/server/mix.exs index 2cea22c503..4bf1bd22b4 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -36,16 +36,32 @@ defmodule Hypr.MixProject do {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, + {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, # {:ash, "~> 3.4"}, {:ash_postgres, "~> 2.4"}, - {:ash_json_api, "~> 1.4"}, # + {:stytch, "~> 0.4.4"}, {:req, "~> 0.5.0"}, {:retry, "~> 0.18"}, {:sentry, "~> 10.8"}, @@ -67,10 +83,17 @@ defmodule Hypr.MixProject do # See the documentation for `Mix` for more info on aliases. defp aliases do [ - setup: ["deps.get", "ecto.setup"], + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind hypr", "esbuild hypr"], + "assets.deploy": [ + "tailwind hypr --minify", + "esbuild hypr --minify", + "phx.digest" + ] ] end end diff --git a/server/mix.lock b/server/mix.lock index 274cadf6cb..0f552885c3 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -1,29 +1,32 @@ %{ - "ash": {:hex, :ash, "3.4.46", "24286834d87719a8d9e0d1addf4b5be4c2acca30c554dbd5d66229d04748a15d", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.8 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "78cd7d1d3ef27516f88a503e181c8e050f80d93222d76696d7491b200bd606db"}, - "ash_json_api": {:hex, :ash_json_api, "1.4.13", "4667094c107a306e0dcf6149f96da4dc33cbec145db5fdf0d20d29b3e31be8e2", [:mix], [{:ash, "~> 3.3", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.58 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "237fd78dd346d3efdc2fcd811d0c4b9a55d07e43572011196b8639db22b19763"}, - "ash_postgres": {:hex, :ash_postgres, "2.4.16", "cb45c71e144590288a8f7cea9573a62e34cd0cbba1b0a9b073da1483caa87086", [:mix], [{:ash, ">= 3.4.44 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.40 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "71bc13fbcb39abd71fc1b8312be9e14c7733688e308d1d2bc21cfb54c8b40098"}, + "ash": {:hex, :ash, "3.4.47", "3d5326b45fc264419347a387b0c9ccf9e032b4d1466aa7b62c737a94585fd232", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.8 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c1e40a04efa8df5e1cbfc71b685ec846c8090a4b19a23be50450659ed259988e"}, + "ash_postgres": {:hex, :ash_postgres, "2.4.17", "cafad136259045f113715cf5bc025b085c21c423315310c71cbe2d57c78312f0", [:mix], [{:ash, ">= 3.4.44 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.40 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "ef6c9b9116edb77d9343e6f36e284760c86fec7a93c6e37f1f431e84c526874f"}, "ash_sql": {:hex, :ash_sql, "0.2.41", "9e0a1686dc67a7cdc8435ced6c998dcd4de87980dde0a72d77946bbc9d1e65cb", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "226470dc8eeb3e89f98c0fb4ef11edf1b114e1caf3cd3457af7f9481df8221e8"}, "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex2ms": {:hex, :ex2ms, "1.7.0", "45b9f523d0b777667ded60070d82d871a37e294f0b6c5b8eca86771f00f82ee1", [:mix], [], "hexpm", "2589eee51f81f1b1caa6d08c990b1ad409215fe6f64c73f73c67d36ed10be827"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.4.8", "6d1bf4934952ac3eb20f6cbac0d5cd6d8012e42e3de20ad794703556c14cfa08", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f9dd06f971fa053c6b0d9f8263b625f619a0fd3645d6a8cd6170935055a8f0df"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "json_xema": {:hex, :json_xema, "0.6.4", "a70ec373f54279369a0bd7c80a8ab2421fb317a68abdf2d9be71b34dbdc1ec78", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.16", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "ee91cfd6720f8ad13245fa2c438e862e6d8f46e2d700c714d72d259dcac7dfbc"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, @@ -36,13 +39,16 @@ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.1", "5389a30658176c0de816636ce276567478bffd063c082515a6e8368b8fc9a0db", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c0f517e6f290f10dbb94343ac22e0109437fb1fa6f0696e7c73967b789c1c285"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, "protobuf": {:hex, :protobuf, "0.13.0", "7a9d9aeb039f68a81717eb2efd6928fdf44f03d2c0dfdcedc7b560f5f5aae93d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "21092a223e3c6c144c1a291ab082a7ead32821ba77073b72c68515aa51fef570"}, - "reactor": {:hex, :reactor, "0.10.2", "a9150cbada58e5331c5250c51c6a8c2d7c4d337919fc71c7dc188a7ae5b6de89", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5c46e71153f540b95e1a240365fc534daead9d19abe0d46eb819b7d715663484"}, + "reactor": {:hex, :reactor, "0.10.3", "41a8c34251148e36dd7c75aa8433f2c2f283f29c097f9eb84a630ab28dd75651", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b34380e22b69a35943a7bcceffd5a8b766870f1fc9052162a7ff74ef9cdb3b2"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, "recon_ex": {:git, "https://github.com/tatsuya6502/recon_ex.git", "0ce4c5da777937a5bb57d3e68b9afcb9877c1c3b", [ref: "0ce4c5d"]}, "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, @@ -50,12 +56,15 @@ "rewrite": {:hex, :rewrite, "1.1.1", "0e6674eb5f8cb11aabe5ad6207151b4156bf173aa9b43133a68f8cc882364570", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "fcd688b3ca543c3a1f1f4615ccc054ec37cfcde91133a27a683ec09b35ae1496"}, "sentry": {:hex, :sentry, "10.8.1", "aa45309785e1521416225adb16e0b4d8b957578804527f3c7babb6fefbc5e456", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "495b3cdadad90ba72eef973aa3dec39b3b8b2a362fe87e2f4ef32133ac3b4097"}, "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, - "spark": {:hex, :spark, "2.2.35", "1c0bb30f340151eca24164885935de39e6ada4010555f444c813d0488990f8f3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f242d6385c287389034a0e146d8f025b5c9ab777f1ae5cf0fdfc9209db6ae748"}, + "spark": {:hex, :spark, "2.2.36", "07c921e5efb27f184267c3431d2f82099e24cac90748a47383dd75cbfb558268", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "e5ac56b75e5ad43da6d8302b6713277488f8e9a3abdba9aae8f0d0f9cff04538"}, "spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"}, "splode": {:hex, :splode, "0.2.7", "ed042fa9bd8fe7b66dd0a0faabdb97352058420d90cd1c7c1537f609deb7ef6d", [:mix], [], "hexpm", "267f1f51d5a5ac988cda0649498294844988c5086916fed5a8aff297d69a2059"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, "stripity_stripe": {:hex, :stripity_stripe, "3.2.0", "07c27f5f2ac87006945b5c997b99d1210e009e380ea78d339d025b11c9c745f5", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uri_query, "~> 0.2.0", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "f797936a9e9538370bae7dc73d73eafd7e44ecdc95b71c88492c43f6df094cb0"}, + "stytch": {:hex, :stytch, "0.4.4", "0685dc5e506e205fbe5e5ab1f03823885e53ca93c3874a046dfa45da6a5ed148", [:mix], [{:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "ba05010035d33ae5e867346bfa37b64944d0a1f6b75531e57020df3bf5642639"}, + "swoosh": {:hex, :swoosh, "1.17.5", "14910d267a2633d4335917b37846e376e2067815601592629366c39845dad145", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "629113d477bc82c4c3bffd15a25e8becc1c7ccc0f0e67743b017caddebb06f04"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, @@ -66,5 +75,4 @@ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, - "xema": {:hex, :xema, "0.17.4", "e958baaf1f8238414c0646a6946a2fa8812673d14771aefc12af182b97d20665", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "faf638de7c424326f089475db8077c86506af971537eb2097e06124c5e0e4240"}, } diff --git a/server/priv/gettext/en/LC_MESSAGES/errors.po b/server/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000000..844c4f5cea --- /dev/null +++ b/server/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/server/priv/gettext/errors.pot b/server/priv/gettext/errors.pot new file mode 100644 index 0000000000..eef2de2ba4 --- /dev/null +++ b/server/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/server/priv/static/images/logo.svg b/server/priv/static/images/logo.svg new file mode 100644 index 0000000000..9f26babac2 --- /dev/null +++ b/server/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/server/rel/env.sh.eex b/server/rel/env.sh.eex deleted file mode 100755 index efeb7ffa27..0000000000 --- a/server/rel/env.sh.eex +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -# configure node for distributed erlang with IPV6 support -export ERL_AFLAGS="-proto_dist inet6_tcp" -export ECTO_IPV6="true" -export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" -export RELEASE_DISTRIBUTION="name" -export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" - -# Uncomment to send crash dumps to stderr -# This can be useful for debugging, but may log sensitive information -# export ERL_CRASH_DUMP=/dev/stderr -# export ERL_CRASH_DUMP_BYTES=4096 diff --git a/server/rel/overlays/bin/migrate b/server/rel/overlays/bin/migrate deleted file mode 100755 index cd09bb1356..0000000000 --- a/server/rel/overlays/bin/migrate +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -cd -P -- "$(dirname -- "$0")" -exec ./hypr eval Hypr.Release.migrate diff --git a/server/rel/overlays/bin/migrate.bat b/server/rel/overlays/bin/migrate.bat deleted file mode 100755 index 4815cec932..0000000000 --- a/server/rel/overlays/bin/migrate.bat +++ /dev/null @@ -1 +0,0 @@ -call "%~dp0\hypr" eval Hypr.Release.migrate diff --git a/server/rel/overlays/bin/server b/server/rel/overlays/bin/server deleted file mode 100755 index cf97e0d04b..0000000000 --- a/server/rel/overlays/bin/server +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -cd -P -- "$(dirname -- "$0")" -PHX_SERVER=true exec ./hypr start diff --git a/server/rel/overlays/bin/server.bat b/server/rel/overlays/bin/server.bat deleted file mode 100755 index 34b5b8b8f7..0000000000 --- a/server/rel/overlays/bin/server.bat +++ /dev/null @@ -1,2 +0,0 @@ -set PHX_SERVER=true -call "%~dp0\hypr" start diff --git a/server/test/hypr_web/controllers/error_html_test.exs b/server/test/hypr_web/controllers/error_html_test.exs new file mode 100644 index 0000000000..dd700e8cb2 --- /dev/null +++ b/server/test/hypr_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule HyprWeb.ErrorHTMLTest do + use HyprWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(HyprWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(HyprWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/server/test/hypr_web/controllers/page_controller_test.exs b/server/test/hypr_web/controllers/page_controller_test.exs new file mode 100644 index 0000000000..27d00f3102 --- /dev/null +++ b/server/test/hypr_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule HyprWeb.PageControllerTest do + use HyprWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end