feat(chat): added chat interface and connection to ollama (#10)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
65
src/components/chat_action_bar.rs
Normal file
65
src/components/chat_action_bar.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
|
||||
|
||||
/// Action bar displayed above the chat input with copy, share, and edit buttons.
|
||||
///
|
||||
/// Only visible when there is at least one message in the conversation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `on_copy` - Copies the last assistant response to the clipboard
|
||||
/// * `on_share` - Copies the full conversation as text to the clipboard
|
||||
/// * `on_edit` - Places the last user message back in the input for editing
|
||||
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
|
||||
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
|
||||
/// * `has_user_message` - Whether a user message exists (disables edit if not)
|
||||
#[component]
|
||||
pub fn ChatActionBar(
|
||||
on_copy: EventHandler<()>,
|
||||
on_share: EventHandler<()>,
|
||||
on_edit: EventHandler<()>,
|
||||
has_messages: bool,
|
||||
has_assistant_message: bool,
|
||||
has_user_message: bool,
|
||||
) -> Element {
|
||||
if !has_messages {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-action-bar",
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_assistant_message,
|
||||
title: "Copy last response",
|
||||
onclick: move |_| on_copy.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaCopy,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Copy" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
title: "Copy conversation",
|
||||
onclick: move |_| on_share.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaShareNodes,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Share" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_user_message,
|
||||
title: "Edit last message",
|
||||
onclick: move |_| on_edit.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaPenToSquare,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Edit" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,82 @@
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Render markdown content to HTML using `pulldown-cmark`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `md` - Raw markdown string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// HTML string suitable for `dangerous_inner_html`
|
||||
fn markdown_to_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser};
|
||||
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
|
||||
let parser = Parser::new_ext(md, opts);
|
||||
let mut html = String::with_capacity(md.len() * 2);
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
||||
/// Renders a single chat message bubble with role-based styling.
|
||||
///
|
||||
/// User messages are right-aligned; assistant messages are left-aligned.
|
||||
/// User messages are displayed as plain text, right-aligned.
|
||||
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
|
||||
/// System messages are hidden from the UI.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The chat message to render
|
||||
#[component]
|
||||
pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
// System messages are not rendered in the UI
|
||||
if message.role == ChatRole::System {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let bubble_class = match message.role {
|
||||
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||
ChatRole::System => "chat-bubble chat-bubble--system",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
let role_label = match message.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => "System",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
// Format timestamp for display (show time only if today)
|
||||
let display_time = if message.timestamp.len() >= 16 {
|
||||
// Extract HH:MM from ISO 8601
|
||||
message.timestamp[11..16].to_string()
|
||||
} else {
|
||||
message.timestamp.clone()
|
||||
};
|
||||
|
||||
let is_assistant = message.role == ChatRole::Assistant;
|
||||
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role", "{role_label}" }
|
||||
span { class: "chat-bubble-time", "{message.timestamp}" }
|
||||
span { class: "chat-bubble-time", "{display_time}" }
|
||||
}
|
||||
if is_assistant {
|
||||
// Render markdown for assistant messages
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{markdown_to_html(&message.content)}",
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
}
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
if !message.attachments.is_empty() {
|
||||
div { class: "chat-bubble-attachments",
|
||||
for att in &message.attachments {
|
||||
@@ -39,3 +87,45 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a streaming assistant message bubble.
|
||||
///
|
||||
/// While waiting for tokens, shows a "Thinking..." indicator with
|
||||
/// a pulsing dot animation. Once tokens arrive, renders them as
|
||||
/// markdown with a blinking cursor.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The accumulated streaming content so far
|
||||
#[component]
|
||||
pub fn StreamingBubble(content: String) -> Element {
|
||||
if content.is_empty() {
|
||||
// Thinking state -- no tokens yet
|
||||
rsx! {
|
||||
div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
|
||||
div { class: "chat-thinking",
|
||||
span { class: "chat-thinking-dots",
|
||||
span { class: "chat-dot" }
|
||||
span { class: "chat-dot" }
|
||||
span { class: "chat-dot" }
|
||||
}
|
||||
span { class: "chat-thinking-text", "Thinking..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let html = markdown_to_html(&content);
|
||||
rsx! {
|
||||
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role", "Assistant" }
|
||||
}
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{html}",
|
||||
}
|
||||
span { class: "chat-streaming-cursor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
src/components/chat_input_bar.rs
Normal file
69
src/components/chat_input_bar.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Chat input bar with a textarea and send button.
|
||||
///
|
||||
/// Enter sends the message; Shift+Enter inserts a newline.
|
||||
/// The input is disabled during streaming.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input_text` - Two-way bound input text signal
|
||||
/// * `on_send` - Callback fired with the message text when sent
|
||||
/// * `is_streaming` - Whether to disable the input (streaming in progress)
|
||||
#[component]
|
||||
pub fn ChatInputBar(
|
||||
input_text: Signal<String>,
|
||||
on_send: EventHandler<String>,
|
||||
is_streaming: bool,
|
||||
) -> Element {
|
||||
let mut input = input_text;
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-input-bar",
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
placeholder: "Type a message...",
|
||||
disabled: is_streaming,
|
||||
rows: "1",
|
||||
value: "{input}",
|
||||
oninput: move |e: Event<FormData>| {
|
||||
input.set(e.value());
|
||||
},
|
||||
onkeypress: move |e: Event<KeyboardData>| {
|
||||
// Enter sends, Shift+Enter adds newline
|
||||
if e.key() == Key::Enter && !e.modifiers().shift() {
|
||||
e.prevent_default();
|
||||
let text = input.read().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
on_send.call(text);
|
||||
input.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn-primary chat-send-btn",
|
||||
disabled: is_streaming || input.read().trim().is_empty(),
|
||||
onclick: move |_| {
|
||||
let text = input.read().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
on_send.call(text);
|
||||
input.set(String::new());
|
||||
}
|
||||
},
|
||||
if is_streaming {
|
||||
// Stop icon during streaming
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
|
||||
width: 16, height: 16,
|
||||
}
|
||||
} else {
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
|
||||
width: 16, height: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/components/chat_message_list.rs
Normal file
38
src/components/chat_message_list.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::components::{ChatBubble, StreamingBubble};
|
||||
use crate::models::ChatMessage;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Scrollable message list that renders all messages in a chat session.
|
||||
///
|
||||
/// Auto-scrolls to the bottom when new messages arrive or during streaming.
|
||||
/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - All loaded messages for the current session
|
||||
/// * `streaming_content` - Accumulated content from the SSE stream
|
||||
/// * `is_streaming` - Whether a response is currently streaming
|
||||
#[component]
|
||||
pub fn ChatMessageList(
|
||||
messages: Vec<ChatMessage>,
|
||||
streaming_content: String,
|
||||
is_streaming: bool,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
class: "chat-message-list",
|
||||
id: "chat-message-list",
|
||||
if messages.is_empty() && !is_streaming {
|
||||
div { class: "chat-empty",
|
||||
p { "Send a message to start the conversation." }
|
||||
}
|
||||
}
|
||||
for msg in &messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
if is_streaming {
|
||||
StreamingBubble { content: streaming_content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/components/chat_model_selector.rs
Normal file
42
src/components/chat_model_selector.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Dropdown bar for selecting the LLM model for the current chat session.
|
||||
///
|
||||
/// Displays the currently selected model and a list of available models
|
||||
/// from the Ollama instance. Fires `on_change` when the user selects
|
||||
/// a different model.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `selected_model` - The currently active model ID
|
||||
/// * `available_models` - List of model names from Ollama
|
||||
/// * `on_change` - Callback fired with the new model name
|
||||
#[component]
|
||||
pub fn ChatModelSelector(
|
||||
selected_model: String,
|
||||
available_models: Vec<String>,
|
||||
on_change: EventHandler<String>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div { class: "chat-model-bar",
|
||||
label { class: "chat-model-label", "Model:" }
|
||||
select {
|
||||
class: "chat-model-select",
|
||||
value: "{selected_model}",
|
||||
onchange: move |e: Event<FormData>| {
|
||||
on_change.call(e.value());
|
||||
},
|
||||
for model in &available_models {
|
||||
option {
|
||||
value: "{model}",
|
||||
selected: *model == selected_model,
|
||||
"{model}"
|
||||
}
|
||||
}
|
||||
if available_models.is_empty() {
|
||||
option { disabled: true, "No models available" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/components/chat_sidebar.rs
Normal file
226
src/components/chat_sidebar.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use crate::models::{ChatNamespace, ChatSession};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Chat sidebar displaying grouped session list with actions.
|
||||
///
|
||||
/// Sessions are split into "News Chats" and "General" sections.
|
||||
/// Each session item shows the title and relative date, with
|
||||
/// rename and delete actions on hover.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `sessions` - All chat sessions for the user
|
||||
/// * `active_session_id` - Currently selected session ID (highlighted)
|
||||
/// * `on_select` - Callback when a session is clicked
|
||||
/// * `on_new` - Callback to create a new chat session
|
||||
/// * `on_rename` - Callback with `(session_id, new_title)`
|
||||
/// * `on_delete` - Callback with `session_id`
|
||||
#[component]
|
||||
pub fn ChatSidebar(
|
||||
sessions: Vec<ChatSession>,
|
||||
active_session_id: Option<String>,
|
||||
on_select: EventHandler<String>,
|
||||
on_new: EventHandler<()>,
|
||||
on_rename: EventHandler<(String, String)>,
|
||||
on_delete: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Split sessions by namespace
|
||||
let news_sessions: Vec<&ChatSession> = sessions
|
||||
.iter()
|
||||
.filter(|s| s.namespace == ChatNamespace::News)
|
||||
.collect();
|
||||
let general_sessions: Vec<&ChatSession> = sessions
|
||||
.iter()
|
||||
.filter(|s| s.namespace == ChatNamespace::General)
|
||||
.collect();
|
||||
|
||||
// Signal for inline rename state: Option<(session_id, current_value)>
|
||||
let rename_state: Signal<Option<(String, String)>> = use_signal(|| None);
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-sidebar-panel",
|
||||
div { class: "chat-sidebar-header",
|
||||
h3 { "Conversations" }
|
||||
button {
|
||||
class: "btn-icon",
|
||||
title: "New Chat",
|
||||
onclick: move |_| on_new.call(()),
|
||||
"+"
|
||||
}
|
||||
}
|
||||
div { class: "chat-session-list",
|
||||
// News Chats section
|
||||
if !news_sessions.is_empty() {
|
||||
div { class: "chat-namespace-header", "News Chats" }
|
||||
for session in &news_sessions {
|
||||
SessionItem {
|
||||
session: (*session).clone(),
|
||||
is_active: active_session_id.as_deref() == Some(&session.id),
|
||||
rename_state: rename_state,
|
||||
on_select: on_select,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// General section
|
||||
div { class: "chat-namespace-header",
|
||||
if news_sessions.is_empty() { "All Chats" } else { "General" }
|
||||
}
|
||||
if general_sessions.is_empty() {
|
||||
p { class: "chat-empty-hint", "No conversations yet" }
|
||||
}
|
||||
for session in &general_sessions {
|
||||
SessionItem {
|
||||
session: (*session).clone(),
|
||||
is_active: active_session_id.as_deref() == Some(&session.id),
|
||||
rename_state: rename_state,
|
||||
on_select: on_select,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual session item component. Handles rename inline editing.
|
||||
#[component]
|
||||
fn SessionItem(
|
||||
session: ChatSession,
|
||||
is_active: bool,
|
||||
rename_state: Signal<Option<(String, String)>>,
|
||||
on_select: EventHandler<String>,
|
||||
on_rename: EventHandler<(String, String)>,
|
||||
on_delete: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut rename_sig = rename_state;
|
||||
let item_class = if is_active {
|
||||
"chat-session-item chat-session-item--active"
|
||||
} else {
|
||||
"chat-session-item"
|
||||
};
|
||||
|
||||
let is_renaming = rename_sig
|
||||
.read()
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| id == &session.id);
|
||||
|
||||
let session_id = session.id.clone();
|
||||
let session_title = session.title.clone();
|
||||
let date_display = format_relative_date(&session.updated_at);
|
||||
|
||||
if is_renaming {
|
||||
let rename_value = rename_sig
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_default();
|
||||
let sid = session_id.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "{item_class}",
|
||||
input {
|
||||
class: "chat-session-rename-input",
|
||||
r#type: "text",
|
||||
value: "{rename_value}",
|
||||
autofocus: true,
|
||||
oninput: move |e: Event<FormData>| {
|
||||
let val = e.value();
|
||||
let id = sid.clone();
|
||||
rename_sig.set(Some((id, val)));
|
||||
},
|
||||
onkeypress: move |e: Event<KeyboardData>| {
|
||||
if e.key() == Key::Enter {
|
||||
if let Some((id, val)) = rename_sig.read().clone() {
|
||||
if !val.trim().is_empty() {
|
||||
on_rename.call((id, val));
|
||||
}
|
||||
}
|
||||
rename_sig.set(None);
|
||||
} else if e.key() == Key::Escape {
|
||||
rename_sig.set(None);
|
||||
}
|
||||
},
|
||||
onfocusout: move |_| {
|
||||
if let Some((ref id, ref val)) = *rename_sig.read() {
|
||||
if !val.trim().is_empty() {
|
||||
on_rename.call((id.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
rename_sig.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let sid_select = session_id.clone();
|
||||
let sid_delete = session_id.clone();
|
||||
let sid_rename = session_id.clone();
|
||||
let title_for_rename = session_title.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "{item_class}",
|
||||
onclick: move |_| on_select.call(sid_select.clone()),
|
||||
div { class: "chat-session-info",
|
||||
span { class: "chat-session-title", "{session_title}" }
|
||||
span { class: "chat-session-date", "{date_display}" }
|
||||
}
|
||||
div { class: "chat-session-actions",
|
||||
button {
|
||||
class: "btn-icon-sm",
|
||||
title: "Rename",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
rename_sig.set(Some((
|
||||
sid_rename.clone(),
|
||||
title_for_rename.clone(),
|
||||
)));
|
||||
},
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaPen,
|
||||
width: 12, height: 12,
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-icon-sm btn-icon-danger",
|
||||
title: "Delete",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
on_delete.call(sid_delete.clone());
|
||||
},
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaTrash,
|
||||
width: 12, height: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an ISO 8601 timestamp as a relative date string.
|
||||
fn format_relative_date(iso: &str) -> String {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(dt);
|
||||
|
||||
if diff.num_minutes() < 1 {
|
||||
"just now".to_string()
|
||||
} else if diff.num_hours() < 1 {
|
||||
format!("{}m ago", diff.num_minutes())
|
||||
} else if diff.num_hours() < 24 {
|
||||
format!("{}h ago", diff.num_hours())
|
||||
} else if diff.num_days() < 7 {
|
||||
format!("{}d ago", diff.num_days())
|
||||
} else {
|
||||
dt.format("%b %d").to_string()
|
||||
}
|
||||
} else {
|
||||
iso.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
mod app_shell;
|
||||
mod article_detail;
|
||||
mod card;
|
||||
mod chat_action_bar;
|
||||
mod chat_bubble;
|
||||
mod chat_input_bar;
|
||||
mod chat_message_list;
|
||||
mod chat_model_selector;
|
||||
mod chat_sidebar;
|
||||
mod dashboard_sidebar;
|
||||
mod file_row;
|
||||
mod login;
|
||||
@@ -16,7 +21,12 @@ mod tool_card;
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
pub use card::*;
|
||||
pub use chat_action_bar::*;
|
||||
pub use chat_bubble::*;
|
||||
pub use chat_input_bar::*;
|
||||
pub use chat_message_list::*;
|
||||
pub use chat_model_selector::*;
|
||||
pub use chat_sidebar::*;
|
||||
pub use dashboard_sidebar::*;
|
||||
pub use file_row::*;
|
||||
pub use login::*;
|
||||
|
||||
@@ -245,6 +245,11 @@ fn SidebarFooter() -> Element {
|
||||
Icon { icon: BsGrid, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-legal",
|
||||
Link { to: Route::PrivacyPage {}, class: "legal-link", "Privacy Policy" }
|
||||
span { class: "legal-sep", "|" }
|
||||
Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" }
|
||||
}
|
||||
p { class: "sidebar-version", "v{version}" }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user