1use topcoat::{
4 Result,
5 context::Cx,
6 view::{BoxView, ViewExt, view},
7};
8
9use super::{super::Panel, current};
10
11#[derive(Debug, Clone)]
14pub struct Brand {
15 pub name: String,
17 pub logo: Option<String>,
19 pub dark_logo: Option<String>,
21 pub favicon: Option<String>,
24 palette: Palette,
26 radius: Option<Radius>,
27}
28
29#[derive(Debug, Clone, Default)]
31struct Palette {
32 color: Option<String>,
33 dark_color: Option<String>,
34 on_color: Option<String>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum Radius {
41 None,
43 Small,
45 Medium,
47 Large,
49 ExtraLarge,
51}
52
53impl Radius {
54 const fn css(self) -> &'static str {
55 match self {
56 Self::None => "0rem",
57 Self::Small => "0.25rem",
58 Self::Medium => "0.5rem",
59 Self::Large => "0.75rem",
60 Self::ExtraLarge => "1rem",
61 }
62 }
63}
64
65impl Brand {
66 pub fn new(name: impl Into<String>) -> Self {
69 Self {
70 name: name.into().trim().to_string(),
71 logo: None,
72 dark_logo: None,
73 favicon: None,
74 palette: Palette::default(),
75 radius: None,
76 }
77 }
78
79 pub fn color(mut self, color: impl Into<String>) -> Self {
84 self.palette.color = Some(color.into().trim().to_string());
85 self
86 }
87
88 pub fn dark_color(mut self, color: impl Into<String>) -> Self {
91 self.palette.dark_color = Some(color.into().trim().to_string());
92 self
93 }
94
95 pub fn on_color(mut self, color: impl Into<String>) -> Self {
98 self.palette.on_color = Some(color.into().trim().to_string());
99 self
100 }
101
102 pub fn radius(mut self, radius: Radius) -> Self {
104 self.radius = Some(radius);
105 self
106 }
107
108 pub(crate) fn declaration_errors(&self) -> Vec<crate::DeclarationErrorKind> {
110 let Palette {
111 color,
112 dark_color,
113 on_color,
114 } = &self.palette;
115 [color, dark_color, on_color]
116 .into_iter()
117 .flatten()
118 .filter(|value| !crate::column::is_css_color(value))
119 .map(|value| crate::DeclarationErrorKind::BrandColor {
120 value: value.clone(),
121 })
122 .collect()
123 }
124
125 pub(crate) fn style(&self) -> Option<String> {
128 if !self.declaration_errors().is_empty() {
129 return None;
130 }
131 let Palette {
132 color,
133 dark_color,
134 on_color,
135 } = &self.palette;
136 let tokens = |primary: Option<&String>| {
137 let mut tokens = String::new();
138 if let Some(primary) = primary {
139 tokens.push_str(&format!("--primary:{primary};--ring:{primary};"));
140 }
141 if let Some(on) = on_color {
142 tokens.push_str(&format!("--primary-foreground:{on};"));
143 }
144 tokens
145 };
146 let mut light = tokens(color.as_ref());
147 if let Some(radius) = self.radius {
148 light.push_str(&format!("--radius:{};", radius.css()));
149 }
150 let dark = tokens(dark_color.as_ref().or(color.as_ref()));
151 let mut style = String::new();
152 if !light.is_empty() {
153 style.push_str(&format!(":root{{{light}}}"));
154 }
155 if !dark.is_empty() {
156 style.push_str(&format!(":root.dark{{{dark}}}"));
157 }
158 (!style.is_empty()).then_some(style)
159 }
160
161 pub fn logo(mut self, logo: impl Into<String>) -> Self {
165 let logo = logo.into().trim().to_string();
166 if !logo.is_empty() {
167 self.logo = Some(logo);
168 }
169 self
170 }
171
172 pub fn dark_logo(mut self, logo: impl Into<String>) -> Self {
176 let logo = logo.into().trim().to_string();
177 if !logo.is_empty() {
178 self.dark_logo = Some(logo);
179 }
180 self
181 }
182
183 pub fn favicon(mut self, favicon: impl Into<String>) -> Self {
185 let favicon = favicon.into().trim().to_string();
186 if !favicon.is_empty() {
187 self.favicon = Some(favicon);
188 }
189 self
190 }
191}
192
193struct Logos {
196 light: String,
197 dark: Option<String>,
198}
199
200#[derive(Clone, Copy)]
202enum BrandSize {
203 Sidebar,
205 Page,
207}
208
209impl Panel {
210 fn current_brand(cx: &Cx) -> (String, Option<Logos>) {
213 match current(cx).and_then(|panel| panel.brand.as_ref()) {
214 Some(brand) => (
215 brand.name.clone(),
216 brand.logo.clone().map(|light| Logos {
217 light,
218 dark: brand.dark_logo.clone(),
219 }),
220 ),
221 None => ("Tablo".to_string(), None),
222 }
223 }
224
225 pub(crate) fn brand_name(cx: &Cx) -> String {
227 Self::current_brand(cx).0
228 }
229
230 fn brand_mark<'a>(cx: &'a Cx, name: &str, logo: Option<Logos>, size: BrandSize) -> BoxView<'a> {
233 let (width, height, logo_class, initial_class) = match size {
235 BrandSize::Sidebar => (
236 Some("28"),
237 "28",
238 "size-7 shrink-0 rounded-md",
239 "flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-sm \
240 font-semibold text-primary-foreground",
241 ),
242 BrandSize::Page => (
243 None,
244 "40",
245 "h-10 w-auto shrink-0",
246 "flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary text-lg \
247 font-semibold text-primary-foreground",
248 ),
249 };
250 match logo {
251 Some(Logos { light, dark }) => {
252 let alt = match size {
255 BrandSize::Sidebar => name.to_string(),
256 BrandSize::Page => String::new(),
257 };
258 let light_class = match dark {
261 Some(_) => format!("{logo_class} dark:hidden"),
262 None => logo_class.to_string(),
263 };
264 let dark_class = format!("{logo_class} hidden dark:block");
265 view! {
266 <img
267 src=(light)
268 alt=(alt.clone())
269 width=(width)
270 height=(height)
271 class=(light_class)
272 >
273 if let Some(dark_url) = dark {
274 <img
275 src=(dark_url)
276 alt=(alt)
277 width=(width)
278 height=(height)
279 class=(dark_class)
280 >
281 }
282 }
283 .boxed()
284 }
285 None => {
286 let initial = name.chars().next().map(String::from).unwrap_or_default();
287 view! { <span aria-hidden="true" class=(initial_class)>(initial)</span> }.boxed()
288 }
289 }
290 }
291
292 pub(crate) async fn render_brand(cx: &Cx) -> Result<BoxView<'_>> {
294 let (name, logo) = Self::current_brand(cx);
295 let mark = Self::brand_mark(cx, &name, logo, BrandSize::Sidebar);
296 Ok(view! {
297 <div class="flex min-w-0 items-center gap-2 font-semibold text-foreground">
298 (mark)
299 <span class="truncate md:group-data-[collapsible=icon]/sidebar:hidden">
300 (name)
301 </span>
302 </div>
303 }
304 .boxed())
305 }
306
307 pub(crate) fn render_page_brand(cx: &Cx) -> BoxView<'_> {
309 let (name, logo) = Self::current_brand(cx);
310 let mark = Self::brand_mark(cx, &name, logo, BrandSize::Page);
311 view! {
312 <div class="flex flex-col items-center gap-3 text-center" data-brand="">
313 (mark)
314 <span class="text-lg font-semibold tracking-tight text-foreground">
315 (name)
316 </span>
317 </div>
318 }
319 .boxed()
320 }
321}