diff --git a/Ticky.Base/Constants.cs b/Ticky.Base/Constants.cs index cdfec59..469b853 100644 --- a/Ticky.Base/Constants.cs +++ b/Ticky.Base/Constants.cs @@ -72,6 +72,24 @@ public static class Limits public const int DEBOUNCE_TIME_IN_MS = 1000; } + public static class Attachments + { + public const string AVIF_FILE_EXTENSION = ".avif"; + public const string AVIF_CONTENT_TYPE = "image/avif"; + + public static readonly HashSet IMAGE_FILE_EXTENSIONS = + new(StringComparer.OrdinalIgnoreCase) + { + AVIF_FILE_EXTENSION, + ".bmp", + ".gif", + ".jpeg", + ".jpg", + ".png", + ".webp" + }; + } + public static class Mappings { public const string LOGIN_PATH = "/auth/login"; @@ -79,6 +97,7 @@ public static class Mappings public const string BOARD_PATH = "/boards"; public const string ATTACHMENTS_API_PATH = "/api/attachments"; public const string ATTACHMENTS_DOWNLOAD_PATH = ATTACHMENTS_API_PATH + "/download"; + public const string ATTACHMENTS_PREVIEW_PATH = ATTACHMENTS_API_PATH + "/preview"; } public static class Policies diff --git a/Ticky.Internal/Helpers/AttachmentHelper.cs b/Ticky.Internal/Helpers/AttachmentHelper.cs index cf768ff..b3f1e93 100644 --- a/Ticky.Internal/Helpers/AttachmentHelper.cs +++ b/Ticky.Internal/Helpers/AttachmentHelper.cs @@ -2,13 +2,17 @@ public static class AttachmentHelper { + public static bool IsImage(Attachment attachment) + { + var extension = Path.GetExtension(attachment.OriginalName); + return Constants.Attachments.IMAGE_FILE_EXTENSIONS.Contains(extension); + } + public static string GetFileTypeFromAttachment(Attachment attachment) { if (attachment.OriginalName.Contains(".doc")) return "DOC"; - else if ( - attachment.OriginalName.Contains(".jpg") || attachment.OriginalName.Contains(".png") - ) + else if (IsImage(attachment)) return "Image"; else if (attachment.OriginalName.Contains(".pdf")) return "PDF"; diff --git a/Ticky.Web/Components/Dialogs/EditCardModal.razor b/Ticky.Web/Components/Dialogs/EditCardModal.razor index 7f727aa..8bd91dc 100644 --- a/Ticky.Web/Components/Dialogs/EditCardModal.razor +++ b/Ticky.Web/Components/Dialogs/EditCardModal.razor @@ -265,27 +265,61 @@
ATTACHMENTS
-
} else if (_tab == 1) @@ -874,6 +908,11 @@ await _js.InvokeVoidAsync("triggerFileDownload", attachment.OriginalName, $"{Constants.Mappings.ATTACHMENTS_DOWNLOAD_PATH}/{attachment.FileName}"); } + private static string GetAttachmentPreviewUrl(Attachment attachment) + { + return $"{Constants.Mappings.ATTACHMENTS_PREVIEW_PATH}/{Uri.EscapeDataString(attachment.FileName)}"; + } + private async Task DeleteAttachment(Attachment attachment) { if(_card is null) @@ -1297,4 +1336,4 @@ await db.SaveChangesAsync(); await UpdateCard(); } -} \ No newline at end of file +} diff --git a/Ticky.Web/Controllers/AttachmentsController.cs b/Ticky.Web/Controllers/AttachmentsController.cs index ad63aed..2130e53 100644 --- a/Ticky.Web/Controllers/AttachmentsController.cs +++ b/Ticky.Web/Controllers/AttachmentsController.cs @@ -53,7 +53,53 @@ public async Task Download(string fileName) } catch (Exception ex) { - _logger.LogError(ex, "Error while downloading attachment {FileName}", decodedFileName); + _logger.LogError(ex, "Error while downloading an attachment"); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpGet("preview/{*fileName}")] + public async Task Preview(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return BadRequest(); + + var decodedFileName = WebUtility.UrlDecode(fileName); + string[] forbiddenChars = ["..", "/", "\\", "\n", "\r"]; + + if (forbiddenChars.Any(decodedFileName.Contains)) + return BadRequest(); + + try + { + using var db = _dbContextFactory.CreateDbContext(); + var attachment = await db.Attachments.FirstOrDefaultAsync(x => + x.FileName == decodedFileName + ); + + if (attachment is null || !AttachmentHelper.IsImage(attachment)) + return NotFound(); + + var absolutePath = Path.GetFullPath( + Path.Combine(Constants.SAVE_UPLOADED_FILES_PATH, attachment.FileName) + ); + + if (!System.IO.File.Exists(absolutePath)) + return NotFound(); + + var contentTypeProvider = + new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); + contentTypeProvider.Mappings[Constants.Attachments.AVIF_FILE_EXTENSION] = + Constants.Attachments.AVIF_CONTENT_TYPE; + + if (!contentTypeProvider.TryGetContentType(attachment.OriginalName, out var contentType)) + return NotFound(); + + return PhysicalFile(absolutePath, contentType, enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while previewing an attachment"); return StatusCode(StatusCodes.Status500InternalServerError); } } diff --git a/Ticky.Web/wwwroot/css/app.css b/Ticky.Web/wwwroot/css/app.css index 851e92b..804c30f 100644 --- a/Ticky.Web/wwwroot/css/app.css +++ b/Ticky.Web/wwwroot/css/app.css @@ -1,4 +1,4 @@ -/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ +/*! tailwindcss v4.1.8 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { @@ -28,6 +28,7 @@ --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-600: oklch(44.6% 0.03 256.802); --color-stone-500: oklch(55.3% 0.013 58.071); + --color-black: #000; --color-white: #fff; --spacing: 0.25rem; --container-2xl: 42rem; @@ -56,10 +57,11 @@ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; + --radius-xl: 0.75rem; --radius-2xl: 1rem; --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --animate-spin: spin 1s linear infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --blur-sm: 8px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -246,9 +248,6 @@ ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } - ::-webkit-calendar-picker-indicator { - line-height: 1; - } :-moz-ui-invalid { box-shadow: none; } @@ -278,15 +277,27 @@ .static { position: static; } + .inset-x-0 { + inset-inline: calc(var(--spacing) * 0); + } .top-0 { top: calc(var(--spacing) * 0); } + .top-2 { + top: calc(var(--spacing) * 2); + } .right-0 { right: calc(var(--spacing) * 0); } + .right-2 { + right: calc(var(--spacing) * 2); + } .right-5 { right: calc(var(--spacing) * 5); } + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } .bottom-5 { bottom: calc(var(--spacing) * 5); } @@ -308,6 +319,9 @@ .mt-1 { margin-top: calc(var(--spacing) * 1); } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } .mt-5 { margin-top: calc(var(--spacing) * 5); } @@ -421,6 +435,9 @@ .h-8 { height: calc(var(--spacing) * 8); } + .h-10 { + height: calc(var(--spacing) * 10); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -430,6 +447,9 @@ .h-32 { height: calc(var(--spacing) * 32); } + .h-48 { + height: calc(var(--spacing) * 48); + } .h-60 { height: calc(var(--spacing) * 60); } @@ -442,12 +462,18 @@ .h-min { height: min-content; } + .max-h-96 { + max-height: calc(var(--spacing) * 96); + } .max-h-\[80vh\] { max-height: 80vh; } .min-h-12 { min-height: calc(var(--spacing) * 12); } + .min-h-56 { + min-height: calc(var(--spacing) * 56); + } .min-h-\[1rem\] { min-height: 1rem; } @@ -478,6 +504,9 @@ .w-9 { width: calc(var(--spacing) * 9); } + .w-10 { + width: calc(var(--spacing) * 10); + } .w-24 { width: calc(var(--spacing) * 24); } @@ -535,6 +564,12 @@ .min-w-80 { min-width: calc(var(--spacing) * 80); } + .flex-1 { + flex: 1; + } + .shrink-0 { + flex-shrink: 0; + } .flex-grow { flex-grow: 1; } @@ -606,6 +641,9 @@ .resize-none { resize: none; } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } .flex-col { flex-direction: column; } @@ -624,6 +662,9 @@ .items-center { align-items: center; } + .items-end { + align-items: flex-end; + } .items-start { align-items: flex-start; } @@ -741,6 +782,9 @@ .rounded-sm { border-radius: var(--radius-sm); } + .rounded-xl { + border-radius: var(--radius-xl); + } .rounded-xs { border-radius: var(--radius-xs); } @@ -760,6 +804,13 @@ border-left-style: var(--tw-border-style); border-left-width: 2px; } + .border-dashed { + --tw-border-style: dashed; + border-style: dashed; + } + .border-add-outline { + border-color: var(--color-add-outline); + } .border-tab-outline { border-color: var(--color-tab-outline); } @@ -790,6 +841,12 @@ background-color: color-mix(in oklab, var(--color-backdrop) 0%, transparent); } } + .bg-black\/60 { + background-color: color-mix(in srgb, #000 60%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 60%, transparent); + } + } .bg-blue-400 { background-color: var(--color-blue-400); } @@ -862,12 +919,44 @@ .bg-yellow-500 { background-color: var(--color-yellow-500); } + .bg-gradient-to-t { + --tw-gradient-position: to top in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } + .from-black\/80 { + --tw-gradient-from: color-mix(in srgb, #000 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-from: color-mix(in oklab, var(--color-black) 80%, transparent); + } + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .via-black\/30 { + --tw-gradient-via: color-mix(in srgb, #000 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-via: color-mix(in oklab, var(--color-black) 30%, transparent); + } + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); + } + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .bg-contain { + background-size: contain; + } .bg-cover { background-size: cover; } .bg-center { background-position: center; } + .bg-no-repeat { + background-repeat: no-repeat; + } + .object-cover { + object-fit: cover; + } .\!p-0 { padding: calc(var(--spacing) * 0) !important; } @@ -937,12 +1026,18 @@ .pt-5 { padding-top: calc(var(--spacing) * 5); } + .pt-10 { + padding-top: calc(var(--spacing) * 10); + } .pr-1 { padding-right: calc(var(--spacing) * 1); } .pr-2 { padding-right: calc(var(--spacing) * 2); } + .pr-8 { + padding-right: calc(var(--spacing) * 8); + } .pb-3 { padding-bottom: calc(var(--spacing) * 3); } @@ -958,6 +1053,9 @@ .text-end { text-align: end; } + .text-left { + text-align: left; + } .text-start { text-align: start; } @@ -1115,6 +1213,9 @@ .opacity-90 { opacity: 90%; } + .opacity-100 { + opacity: 100%; + } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -1139,10 +1240,6 @@ outline-style: var(--tw-outline-style); outline-width: 1px; } - .outline-2 { - outline-style: var(--tw-outline-style); - outline-width: 2px; - } .outline-add-outline { outline-color: var(--color-add-outline); } @@ -1152,8 +1249,13 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } @@ -1167,6 +1269,15 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); @@ -1175,6 +1286,13 @@ -webkit-user-select: none; user-select: none; } + .group-hover\:scale-\[1\.02\] { + &:is(:where(.group):hover *) { + @media (hover: hover) { + scale: 1.02; + } + } + } .hover\:border-add-outline { &:hover { @media (hover: hover) { @@ -1231,6 +1349,13 @@ } } } + .hover\:bg-red-button { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-button); + } + } + } .hover\:\!text-red-button-hover { &:hover { @media (hover: hover) { @@ -1287,6 +1412,14 @@ } } } + .hover\:shadow-md { + &:hover { + @media (hover: hover) { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + } .hover\:outline-add-outline-hover { &:hover { @media (hover: hover) { @@ -1354,6 +1487,11 @@ display: flex; } } + .sm\:col-span-2 { + @media (width >= 40rem) { + grid-column: span 2 / span 2; + } + } .sm\:\!hidden { @media (width >= 40rem) { display: none !important; @@ -1364,6 +1502,11 @@ display: block; } } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } .sm\:gap-5 { @media (width >= 40rem) { gap: calc(var(--spacing) * 5); @@ -1374,6 +1517,27 @@ padding-inline: calc(var(--spacing) * 12); } } + .sm\:opacity-0 { + @media (width >= 40rem) { + opacity: 0%; + } + } + .sm\:group-hover\:opacity-100 { + @media (width >= 40rem) { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } + } + .sm\:focus\:opacity-100 { + @media (width >= 40rem) { + &:focus { + opacity: 100%; + } + } + } .md\:flex { @media (width >= 48rem) { display: flex; @@ -1719,6 +1883,7 @@ button, .label-as-button > i { --tw-gradient-position: to top right in oklab; background-image: linear-gradient(var(--tw-gradient-stops)); --tw-gradient-from: var(--color-auth-gradient-start); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); --tw-gradient-via: var(--color-auth-gradient-via); --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); --tw-gradient-stops: var(--tw-gradient-via-stops); @@ -2380,6 +2545,48 @@ input[type="checkbox"].switch:checked:after { inherits: false; initial-value: 0; } +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} @property --tw-font-weight { syntax: "*"; inherits: false; @@ -2502,66 +2709,64 @@ input[type="checkbox"].switch:checked:after { syntax: "*"; inherits: false; } -@property --tw-translate-x { +@property --tw-backdrop-blur { syntax: "*"; inherits: false; - initial-value: 0; } -@property --tw-translate-y { +@property --tw-backdrop-brightness { syntax: "*"; inherits: false; - initial-value: 0; } -@property --tw-translate-z { +@property --tw-backdrop-contrast { syntax: "*"; inherits: false; - initial-value: 0; } -@property --tw-tracking { +@property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } -@property --tw-gradient-position { +@property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } -@property --tw-gradient-from { - syntax: ""; +@property --tw-backdrop-invert { + syntax: "*"; inherits: false; - initial-value: #0000; } -@property --tw-gradient-via { - syntax: ""; +@property --tw-backdrop-opacity { + syntax: "*"; inherits: false; - initial-value: #0000; } -@property --tw-gradient-to { - syntax: ""; +@property --tw-backdrop-saturate { + syntax: "*"; inherits: false; - initial-value: #0000; } -@property --tw-gradient-stops { +@property --tw-backdrop-sepia { syntax: "*"; inherits: false; } -@property --tw-gradient-via-stops { +@property --tw-duration { syntax: "*"; inherits: false; } -@property --tw-gradient-from-position { - syntax: ""; +@property --tw-translate-x { + syntax: "*"; inherits: false; - initial-value: 0%; + initial-value: 0; } -@property --tw-gradient-via-position { - syntax: ""; +@property --tw-translate-y { + syntax: "*"; inherits: false; - initial-value: 50%; + initial-value: 0; } -@property --tw-gradient-to-position { - syntax: ""; +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-tracking { + syntax: "*"; inherits: false; - initial-value: 100%; } @property --tw-scale-x { syntax: "*"; @@ -2603,6 +2808,15 @@ input[type="checkbox"].switch:checked:after { --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; --tw-font-weight: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; @@ -2631,19 +2845,20 @@ input[type="checkbox"].switch:checked:after { --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; --tw-tracking: initial; - --tw-gradient-position: initial; - --tw-gradient-from: #0000; - --tw-gradient-via: #0000; - --tw-gradient-to: #0000; - --tw-gradient-stops: initial; - --tw-gradient-via-stops: initial; - --tw-gradient-from-position: 0%; - --tw-gradient-via-position: 50%; - --tw-gradient-to-position: 100%; --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1;