use topcoat::{Result, context::Cx, view::*};
use super::{Field, render_field};
use crate::{FieldRuleFault, Text, text};
#[derive(Clone, Debug, Default)]
pub(crate) struct FileControl {
pub(crate) extensions: Option<Vec<String>>,
pub(crate) max_bytes: Option<usize>,
pub(crate) multiple: bool,
pub(crate) max_files: Option<usize>,
pub(crate) image: bool,
}
impl FileControl {
pub(crate) fn refusal(&self, label: &crate::Text, filename: &str, size: usize) -> Option<Text> {
if !self.takes_name(filename) {
let listed = self.extensions.as_deref().unwrap_or_default();
let listed: Vec<String> = listed.iter().map(|ext| format!(".{ext}")).collect();
return Some(text!(
"tablo-field-extension",
label = label,
count = listed.len(),
extensions = listed.join(", ")
));
}
let max = self.max_bytes?;
(size > max).then(|| {
text!(
"tablo-field-max-size",
label = label,
max = spelled_size(max)
)
})
}
pub(crate) fn takes_name(&self, name: &str) -> bool {
let Some(extensions) = &self.extensions else {
return true;
};
name.rsplit_once('.').is_some_and(|(_, ext)| {
extensions
.iter()
.any(|taken| taken.eq_ignore_ascii_case(ext))
})
}
pub(crate) fn takes_path(&self, path: &str) -> bool {
let path = path.split(['?', '#']).next().unwrap_or_default();
self.takes_name(path.rsplit('/').next().unwrap_or_default())
}
fn accept(&self) -> Option<String> {
let extensions = self.extensions.as_ref()?;
let listed: Vec<String> = extensions.iter().map(|ext| format!(".{ext}")).collect();
Some(listed.join(","))
}
pub(crate) fn rule_faults(&self) -> Vec<FieldRuleFault> {
let mut faults = Vec::new();
match self.extensions.as_deref() {
Some([]) => faults.push(FieldRuleFault::NoExtension),
Some(extensions) => faults.extend(
extensions
.iter()
.filter(|ext| ext.is_empty() || !ext.chars().all(|c| c.is_ascii_alphanumeric()))
.map(|ext| FieldRuleFault::InvalidExtension(ext.clone())),
),
None => {}
}
if self.max_files.is_some() && !self.multiple {
faults.push(FieldRuleFault::SingleFile);
}
faults
}
pub(crate) fn count_refusal(&self, label: &crate::Text, count: usize) -> Option<Text> {
let max = self.max_files?;
(count > max).then(|| text!("tablo-field-max-files", label = label, count = max))
}
}
fn spelled_size(bytes: usize) -> Text {
const KIB: usize = 1024;
match bytes {
0 => text!("tablo-size-bytes", count = 0),
_ if bytes.is_multiple_of(KIB * KIB) => {
text!("tablo-size-mib", count = bytes / (KIB * KIB))
}
_ if bytes.is_multiple_of(KIB) => text!("tablo-size-kib", count = bytes / KIB),
_ => text!("tablo-size-bytes", count = bytes),
}
}
impl Field {
pub(crate) fn as_file(&self) -> Option<&FileControl> {
match &self.control {
super::ControlKind::File(file) => Some(file),
_ => None,
}
}
pub(crate) fn is_file_list(&self) -> bool {
self.as_file().is_some_and(|file| file.multiple)
}
pub(super) fn render_file<'a>(
&self,
file: &FileControl,
cx: &'a Cx,
value: Option<&str>,
error: Option<&crate::values::FieldError>,
id: String,
disabled: bool,
) -> Result<BoxView<'a>> {
let name = self.name().to_string();
let stored = stored_path(value);
let is_edit = stored.is_some();
let control_required = self.required && !is_edit && !disabled;
let chrome = self.chrome(cx, id.clone(), error, None);
let hint_id = format!("{id}-hint");
let clear_name = format!("clear_{name}");
let clear_id = format!("clear_{id}");
let image = file.image;
let stored_display: Option<BoxView<'a>> = stored.map(|current| {
let name_id = format!("{id}-file");
let remove = (!disabled).then(|| {
remove_toggle(
cx,
clear_name,
"1".to_owned(),
clear_id,
name_id.clone(),
image,
"tablo-file-remove",
)
});
let tile = StoredTile {
path: current,
name_id,
image,
remove,
moves: None,
keep: None,
movable: false,
}
.render(cx);
view! { <div class="contents">(tile)</div> }.boxed()
});
let aria_invalid = chrome.aria_invalid();
let accept = file.accept();
let described_by = [
chrome.described_by(),
(is_edit && !disabled).then(|| hint_id.clone()),
]
.into_iter()
.flatten()
.reduce(|ids, id| format!("{ids} {id}"));
let input = view! {
<input
class=(INPUT_CLASS)
id=(id)
type="file"
name=(name)
accept=(accept)
required=(control_required)
disabled=(disabled)
aria-required=(control_required.then_some("true"))
aria-invalid=(aria_invalid)
aria-describedby=(described_by)
>
}
.boxed();
let hint = (is_edit && !disabled).then(|| {
view! {
<div class="sr-only" id=(hint_id)>
(crate::t!(cx, "tablo-file-keep"))
</div>
}
.boxed()
});
let control = upload_panel(cx, image, input, "tablo-file-drop", stored_display, hint);
render_field(
cx,
&chrome,
&self.label(),
control_required,
attributes! {},
control,
)
}
}
fn stored_path(value: Option<&str>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
pub(crate) fn is_linkable(path: &str) -> bool {
is_rooted(path) || {
let lower = path.trim_start().to_ascii_lowercase();
is_plain(&lower) && (lower.starts_with("https://") || lower.starts_with("http://"))
}
}
pub(crate) fn is_rooted(path: &str) -> bool {
let path = path.trim_start();
is_plain(path) && path.starts_with('/') && !path.starts_with("//")
}
fn is_plain(url: &str) -> bool {
!url.chars().any(|c| c == '\\' || c.is_control())
}
pub(crate) struct FileHref(pub(crate) fn(&Cx, &str) -> String);
pub(crate) fn file_href(cx: &Cx, path: &str) -> String {
match topcoat::context::try_context::<FileHref>(cx) {
Some(FileHref(sign)) => sign(cx, path),
None => path.to_owned(),
}
}
pub(crate) fn stored_upload<'a>(cx: &'a Cx, value: &str) -> BoxView<'a> {
let path = value.trim().to_string();
if !is_linkable(&path) {
return view! {
<div
class="text-sm text-foreground font-mono break-all whitespace-pre-wrap"
>
(path)
</div>
}
.boxed();
}
let href = file_href(cx, &path);
view! {
<a
class="text-sm font-mono break-all whitespace-pre-wrap underline"
href=(href)
>
(path)
</a>
}
.boxed()
}
mod list;
mod panel;
use panel::{INPUT_CLASS, StoredTile, remove_toggle, upload_panel};
#[cfg(test)]
mod tests;