Update WBBR frontend template collection
This commit is contained in:
246
app/repeatable-form/RepeatableForm.module.css
Normal file
246
app/repeatable-form/RepeatableForm.module.css
Normal file
@@ -0,0 +1,246 @@
|
||||
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
|
||||
.page {
|
||||
--ink: #20283a;
|
||||
--muted: #8790a2;
|
||||
--line: #e5e8ef;
|
||||
--blue: #3d6fe8;
|
||||
--blue-dark: #2d5dcc;
|
||||
--blue-soft: #edf3ff;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fb;
|
||||
color: var(--ink);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.page * { box-sizing: border-box; }
|
||||
.page button, .page input, .page select, .page textarea { font: inherit; }
|
||||
.topbar { position: sticky; top: 0; z-index: 30; height: 64px; display: flex; align-items: center; padding: 0 24px; border-bottom: 1px solid #e3e7ee; background: rgba(255,255,255,.96); backdrop-filter: blur(12px); }
|
||||
.brand { display: flex; align-items: center; gap: 9px; color: #20283a; text-decoration: none; }
|
||||
.brand > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 8px; background: linear-gradient(145deg,#4778ed,#315cc8); color: #fff; font-family: Georgia,serif; font-size: 15px; box-shadow: 0 5px 12px rgba(50,91,199,.24); }
|
||||
.brand strong { font-size: 13px; letter-spacing: -.2px; }
|
||||
.topbar nav { height: 100%; display: flex; align-items: stretch; gap: 4px; margin-left: 42px; }
|
||||
.topbar nav a { position: relative; display: flex; align-items: center; padding: 0 14px; color: #8a92a1; text-decoration: none; font-size: 10px; }
|
||||
.topbar nav a:hover, .topbar nav .activeNav { color: #31415e; }
|
||||
.topbar nav .activeNav::after { content: ""; position: absolute; inset: auto 13px -1px; height: 2px; background: var(--blue); }
|
||||
.saveState { display: flex; align-items: center; gap: 6px; margin-left: auto; color: #9098a7; font-size: 8px; }
|
||||
.saveState > i, .formFooter p > i { width: 6px; height: 6px; border-radius: 50%; background: #e5a94d; }
|
||||
.saveState .savedDot, .formFooter p > .savedDot { background: #31a670; }
|
||||
.headerActions { display: flex; align-items: center; gap: 7px; margin-left: 20px; }
|
||||
.headerActions button { height: 32px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 7px; background: #fff; color: #616d80; cursor: pointer; font-size: 9px; }
|
||||
.headerActions button:hover { border-color: #b8c8e9; }
|
||||
.headerActions .saveButton { border-color: var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(61,111,232,.2); }
|
||||
.headerActions > span { width: 30px; height: 30px; display: grid; place-items: center; margin-left: 4px; border-radius: 50%; background: #dfe9ff; color: #315daa; font-size: 9px; font-weight: 700; }
|
||||
|
||||
.workspace { min-height: calc(100vh - 64px); display: grid; grid-template-columns: 256px minmax(0,1fr); }
|
||||
.sidebar { position: sticky; top: 64px; height: calc(100vh - 64px); display: flex; flex-direction: column; border-right: 1px solid #e2e6ed; background: #fff; }
|
||||
.sidebarTitle { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px 0 19px; }
|
||||
.sidebarTitle p { margin: 0 0 3px; color: #7891cc; font-size: 6px; font-weight: 800; letter-spacing: 1.3px; }
|
||||
.sidebarTitle strong { font-size: 13px; }
|
||||
.sidebarTitle button { width: 30px; height: 30px; border: 0; border-radius: 7px; background: var(--blue); color: #fff; cursor: pointer; font-size: 16px; }
|
||||
.search { height: 33px; display: flex; align-items: center; gap: 7px; margin: 0 14px 11px; padding: 0 9px; border: 1px solid #e1e5eb; border-radius: 7px; background: #f8f9fb; color: #9da5b2; }
|
||||
.search:focus-within { border-color: #9db7ef; background: #fff; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
|
||||
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 8px; }
|
||||
.statusTabs { display: flex; gap: 3px; padding: 0 13px 11px; border-bottom: 1px solid #eef0f3; overflow-x: auto; }
|
||||
.statusTabs button { padding: 5px 7px; border: 0; border-radius: 5px; background: none; color: #9aa1ad; cursor: pointer; font-size: 7px; white-space: nowrap; }
|
||||
.statusTabs .statusTabActive { background: var(--blue-soft); color: #3a67cf; font-weight: 700; }
|
||||
.formList { flex: 1; overflow-y: auto; padding: 9px; }
|
||||
.formList > button { width: 100%; display: flex; align-items: flex-start; gap: 9px; margin-bottom: 5px; padding: 11px 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #5b6576; cursor: pointer; text-align: left; }
|
||||
.formList > button:hover { background: #f7f9fc; }
|
||||
.formList > .formActive { border-color: #d8e3fb; background: #f1f5ff; box-shadow: inset 3px 0 #4776e5; }
|
||||
.formIcon { width: 31px; height: 31px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #edf0f5; color: #7a879a; font-size: 12px; }
|
||||
.formActive .formIcon { background: #dfe9ff; color: #3d6bd5; }
|
||||
.formList > button > div { min-width: 0; flex: 1; }
|
||||
.formList strong { display: block; overflow: hidden; color: #3e4859; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.formList small { display: block; margin: 3px 0 7px; color: #a1a8b4; font-size: 6px; }
|
||||
.formList p { display: flex; align-items: center; gap: 4px; margin: 0; color: #8f97a5; font-size: 6px; }
|
||||
.formList p > i { width: 5px; height: 5px; border-radius: 50%; background: #9ca4b0; }
|
||||
.formList p > .recordEditing { background: #e6a23c; }
|
||||
.formList p > .recordSubmitted { background: #32a76f; }
|
||||
.formList p > .recordDraft { background: #929aa7; }
|
||||
.formList p > span { margin-left: 4px; padding-left: 6px; border-left: 1px solid #dfe2e8; }
|
||||
.formList time { margin-left: auto; }
|
||||
.noForms { padding: 35px 10px; color: #a1a7b2; font-size: 8px; text-align: center; }
|
||||
.sidebarFooter { padding: 10px 12px 13px; border-top: 1px solid #e9ebef; }
|
||||
.sidebarFooter button { width: 100%; display: block; padding: 8px; border: 0; border-radius: 6px; background: none; color: #717b8c; cursor: pointer; text-align: left; font-size: 8px; }
|
||||
.sidebarFooter button:hover { background: #f3f5f8; color: var(--blue); }
|
||||
.sidebarFooter p { margin: 9px 8px 0; color: #939ba8; font-size: 7px; line-height: 1.5; }
|
||||
.sidebarFooter p span { color: #b0b6bf; }
|
||||
|
||||
.main { width: min(1120px,100%); margin: 0 auto; padding: 25px 32px 80px; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 7px; margin-bottom: 19px; color: #a1a8b4; font-size: 7px; }
|
||||
.breadcrumb a { color: #7b8aa3; text-decoration: none; }
|
||||
.breadcrumb strong { color: #657087; font-weight: 600; }
|
||||
.formHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 21px; }
|
||||
.titleGroup { display: grid; grid-template-columns: auto minmax(0,1fr); align-items: center; column-gap: 9px; }
|
||||
.titleGroup input { width: min(650px,65vw); padding: 0; border: 0; outline: 0; background: transparent; color: #1f293b; font-size: 24px; font-weight: 750; letter-spacing: -.6px; }
|
||||
.titleGroup p { grid-column: 1/-1; margin: 8px 0 0; color: #9199a7; font-size: 8px; }
|
||||
.formStatus { padding: 5px 8px; border-radius: 5px; font-size: 7px; font-weight: 700; }
|
||||
.formStatusEditing { background: #fff2dd; color: #c67d24; }
|
||||
.formStatusSubmitted { background: #e7f7ef; color: #27875b; }
|
||||
.formStatusDraft { background: #eceef2; color: #757e8d; }
|
||||
.headerMenu { display: flex; gap: 6px; }
|
||||
.headerMenu button { height: 30px; padding: 0 10px; border: 1px solid #e0e3e9; border-radius: 6px; background: #fff; color: #778194; cursor: pointer; font-size: 8px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4,1fr); overflow: hidden; margin-bottom: 17px; border: 1px solid var(--line); border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(34,47,75,.035); }
|
||||
.summary > div { min-height: 78px; padding: 16px 20px; border-right: 1px solid #eef0f4; }
|
||||
.summary > div:last-child { border-right: 0; }
|
||||
.summary span { display: block; margin-bottom: 6px; color: #8f98a8; font-size: 8px; }
|
||||
.summary strong { color: #29354a; font-family: Georgia,serif; font-size: 20px; font-weight: 500; }
|
||||
.summary strong em { color: #a4aab4; font-size: 10px; font-style: normal; }
|
||||
.summary small { float: right; margin-top: 8px; color: #a5abb6; font-size: 7px; }
|
||||
.warning { min-height: 48px; display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px 13px; border: 1px solid #f0d59d; border-radius: 8px; background: #fff9ec; color: #9b701f; }
|
||||
.warning > span { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #e9ad3e; color: #fff; font-weight: 800; }
|
||||
.warning p { flex: 1; margin: 0; font-size: 8px; line-height: 1.6; }
|
||||
.warning p strong { display: block; }
|
||||
.warning button { border: 0; background: none; color: inherit; cursor: pointer; }
|
||||
.sectionBar { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 26px 0 12px; }
|
||||
.sectionBar p { margin: 0 0 4px; color: var(--blue); font-size: 6px; font-weight: 800; letter-spacing: 1.5px; }
|
||||
.sectionBar h2 { display: inline; margin: 0; font-size: 14px; }
|
||||
.sectionBar div > span { margin-left: 9px; color: #969eab; font-size: 8px; }
|
||||
.sectionBar > button { height: 32px; padding: 0 13px; border: 1px solid #cad8f5; border-radius: 7px; background: #fff; color: #3d6bd4; cursor: pointer; font-size: 8px; font-weight: 700; }
|
||||
|
||||
.itemList { display: flex; flex-direction: column; gap: 12px; }
|
||||
.itemCard { scroll-margin-top: 78px; overflow: hidden; border: 1px solid #e1e5ec; border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(38,50,75,.035); }
|
||||
.itemHeader { min-height: 59px; display: flex; align-items: center; gap: 10px; padding: 9px 13px; border-bottom: 1px solid #edf0f4; background: #fcfcfd; }
|
||||
.dragHandle { color: #c2c7cf; cursor: grab; font-size: 11px; letter-spacing: -3px; }
|
||||
.itemNumber { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #e9effe; color: #456dcb; font-family: Georgia,serif; font-size: 10px; }
|
||||
.itemHeader > div:nth-of-type(1) { min-width: 0; flex: 1; }
|
||||
.itemHeader strong { display: block; overflow: hidden; color: #334056; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.itemHeader small { display: block; margin-top: 4px; color: #9ba3b0; font-size: 7px; }
|
||||
.itemStatus { padding: 4px 7px; border-radius: 10px; font-size: 7px; }
|
||||
.itemStatusEmpty { background: #eff1f4; color: #7d8593; }
|
||||
.itemStatusDoing { background: #eaf1ff; color: #3d6cd5; }
|
||||
.itemStatusReview { background: #fff1db; color: #c57d22; }
|
||||
.itemStatusDone { background: #e5f7ee; color: #2c9463; }
|
||||
.itemActions { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
|
||||
.itemActions button { height: 25px; padding: 0 7px; border: 0; border-radius: 5px; background: transparent; color: #8992a1; cursor: pointer; font-size: 7px; }
|
||||
.itemActions button:hover:not(:disabled) { background: #eef2f8; color: #4369c2; }
|
||||
.itemActions button:disabled { opacity: .3; cursor: not-allowed; }
|
||||
.itemActions .deleteAction:hover { background: #fff0f0; color: #d65353; }
|
||||
.itemBody { padding: 20px; }
|
||||
.fieldsGrid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 13px; }
|
||||
.fieldsGrid label { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.fieldsGrid label > span { color: #5f697a; font-size: 8px; font-weight: 650; }
|
||||
.fieldsGrid label > span b { color: #df5656; }
|
||||
.fieldsGrid input, .fieldsGrid select, .fieldsGrid textarea { width: 100%; border: 1px solid #dfe3ea; border-radius: 7px; outline: 0; background: #fff; color: #465267; font-size: 9px; }
|
||||
.fieldsGrid input, .fieldsGrid select { height: 34px; padding: 0 10px; }
|
||||
.fieldsGrid textarea { height: 67px; resize: vertical; padding: 9px 10px; line-height: 1.6; }
|
||||
.fieldsGrid input:focus, .fieldsGrid select:focus, .fieldsGrid textarea:focus { border-color: #93b0ed; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
|
||||
.wideField { grid-column: span 2; }
|
||||
.descriptionField { grid-column: span 4; }
|
||||
.attachmentSection { margin-top: 20px; padding-top: 17px; border-top: 1px solid #eef0f3; }
|
||||
.attachmentHeading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 12px; }
|
||||
.attachmentHeading > div { display: flex; align-items: center; gap: 9px; }
|
||||
.imageBadge, .fileBadge { width: 29px; height: 29px; display: grid; place-items: center; border-radius: 7px; background: #e9f0ff; color: #4270d6; font-size: 11px; }
|
||||
.fileBadge { background: #efeaff; color: #7156bf; }
|
||||
.attachmentHeading p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
|
||||
.attachmentHeading strong { color: #4d586a; font-size: 8px; }
|
||||
.attachmentHeading small { color: #a1a8b3; font-size: 7px; }
|
||||
.uploadButton { height: 29px; display: flex; align-items: center; padding: 0 10px; border: 1px solid #dbe1eb; border-radius: 6px; background: #fff; color: #526482; cursor: pointer; font-size: 7px; }
|
||||
.uploadButton:hover { border-color: #9eb4e3; color: var(--blue); }
|
||||
.uploadButton input, .emptyImage input, .emptyFile input { display: none; }
|
||||
.imageGrid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 9px; }
|
||||
.imageGrid figure { min-width: 0; overflow: hidden; margin: 0; border: 1px solid #e1e5eb; border-radius: 8px; background: #fafbfc; }
|
||||
.imagePreview { position: relative; width: 100%; height: 112px; display: block; overflow: hidden; padding: 0; border: 0; background: #e9edf3; cursor: zoom-in; }
|
||||
.imagePreview img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform .2s; }
|
||||
.imagePreview > span { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(22,31,48,.52); color: #fff; font-size: 8px; opacity: 0; transition: opacity .2s; }
|
||||
.imagePreview:hover img { transform: scale(1.035); }
|
||||
.imagePreview:hover > span { opacity: 1; }
|
||||
.imageGrid figcaption { min-height: 42px; display: flex; align-items: center; gap: 6px; padding: 7px 8px; }
|
||||
.imageGrid figcaption p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 2px; margin: 0; }
|
||||
.imageGrid figcaption strong { overflow: hidden; color: #596477; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.imageGrid figcaption small { color: #a0a7b2; font-size: 6px; }
|
||||
.imageGrid figcaption button { width: 21px; height: 21px; border: 0; border-radius: 4px; background: none; color: #a6acb6; cursor: pointer; }
|
||||
.imageGrid figcaption button:hover { background: #fff0f0; color: #d75c5c; }
|
||||
.emptyImage { min-height: 155px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e1; border-radius: 8px; background: #fafbfc; color: #9aa3b1; cursor: pointer; }
|
||||
.emptyImage:hover { border-color: #8eabe8; background: #f7f9ff; }
|
||||
.emptyImage > span { width: 31px; height: 31px; display: grid; place-items: center; margin-bottom: 7px; border-radius: 50%; background: #eaf0fd; color: #557bd3; font-size: 14px; }
|
||||
.emptyImage strong { color: #687589; font-size: 8px; }
|
||||
.emptyImage small { margin-top: 4px; font-size: 6px; }
|
||||
.fileList { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fileList > div { min-height: 48px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid #e4e7ed; border-radius: 7px; background: #fbfbfc; }
|
||||
.fileType { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 6px; background: #eeeafd; color: #6d57b7; font-size: 6px; font-weight: 800; }
|
||||
.fileList p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 3px; margin: 0; }
|
||||
.fileList strong { overflow: hidden; color: #536074; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fileList small { color: #a0a7b2; font-size: 6px; }
|
||||
.fileList a, .fileList > div > button { padding: 4px 7px; border: 0; border-radius: 4px; background: transparent; color: #5272bd; cursor: pointer; font-size: 7px; text-decoration: none; }
|
||||
.fileList > div > button:last-child { width: 23px; color: #a2a9b4; font-size: 11px; }
|
||||
.fileList > div > button:last-child:hover { background: #fff0f0; color: #d75c5c; }
|
||||
.emptyFile { min-height: 46px; display: grid; place-items: center; border: 1px dashed #d2d7e0; border-radius: 7px; color: #8e99a9; cursor: pointer; font-size: 8px; }
|
||||
.emptyFile:hover { border-color: #9bafe0; background: #f7f9ff; color: #4f70bd; }
|
||||
.emptyItems { min-height: 260px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e2; border-radius: 11px; background: #fff; color: #98a1af; }
|
||||
.emptyItems > span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 50%; background: #edf2fb; color: #5d7dc4; font-size: 18px; }
|
||||
.emptyItems h3 { margin: 12px 0 3px; color: #556174; font-size: 11px; }
|
||||
.emptyItems p { margin: 0 0 13px; font-size: 8px; }
|
||||
.emptyItems button { height: 31px; padding: 0 12px; border: 0; border-radius: 6px; background: var(--blue); color: #fff; cursor: pointer; font-size: 8px; }
|
||||
.addItemBottom { width: 100%; min-height: 65px; display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; border: 1px dashed #bdcbe5; border-radius: 10px; background: rgba(255,255,255,.55); color: #62718a; cursor: pointer; }
|
||||
.addItemBottom:hover { border-color: #7595db; background: #f8faff; }
|
||||
.addItemBottom > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: #e5edfe; color: #4971ca; }
|
||||
.addItemBottom strong { font-size: 8px; }
|
||||
.addItemBottom small { color: #9fa7b3; font-size: 7px; font-weight: 400; }
|
||||
.formFooter { min-height: 70px; display: flex; align-items: center; justify-content: space-between; margin-top: 22px; padding-top: 18px; border-top: 1px solid #dfe3ea; }
|
||||
.formFooter p { display: flex; align-items: center; gap: 6px; margin: 0; color: #939baa; font-size: 7px; }
|
||||
.formFooter > div { display: flex; gap: 8px; }
|
||||
.formFooter button { height: 34px; padding: 0 14px; border: 1px solid #dce1e9; border-radius: 7px; background: #fff; color: #687487; cursor: pointer; font-size: 8px; }
|
||||
.formFooter .submitButton { border-color: var(--blue); background: var(--blue); color: #fff; }
|
||||
.lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 35px; background: rgba(12,19,31,.72); backdrop-filter: blur(7px); }
|
||||
.lightbox > div { width: min(980px,95vw); max-height: 88vh; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 30px 90px rgba(3,8,18,.45); }
|
||||
.lightbox header { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 15px; }
|
||||
.lightbox header p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
|
||||
.lightbox header strong { font-size: 9px; }
|
||||
.lightbox header small { color: #9aa2ae; font-size: 7px; }
|
||||
.lightbox header button { width: 30px; height: 30px; border: 0; border-radius: 50%; background: #f1f2f5; color: #717a89; cursor: pointer; }
|
||||
.lightbox img { width: 100%; max-height: calc(88vh - 52px); display: block; object-fit: contain; background: #101522; }
|
||||
.toast { position: fixed; left: 50%; bottom: 25px; z-index: 70; padding: 10px 17px; border-radius: 22px; background: rgba(29,38,55,.96); color: #fff; box-shadow: 0 12px 30px rgba(22,30,44,.25); font-size: 8px; animation: toastIn .22s ease both; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.topbar nav { display: none; }
|
||||
.workspace { grid-template-columns: 220px minmax(0,1fr); }
|
||||
.sidebar { width: 220px; }
|
||||
.main { padding: 23px 20px 65px; }
|
||||
.fieldsGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.wideField { grid-column: span 2; }
|
||||
.descriptionField { grid-column: span 2; }
|
||||
.imageGrid { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||
.itemActions button:nth-child(-n+2) { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { padding: 0 13px; }
|
||||
.saveState, .headerActions > button:first-child, .headerActions > span { display: none; }
|
||||
.headerActions { margin-left: auto; }
|
||||
.workspace { display: block; }
|
||||
.sidebar { position: relative; top: auto; width: 100%; height: auto; border-right: 0; border-bottom: 1px solid #e2e6ed; }
|
||||
.sidebarTitle { height: 58px; }
|
||||
.formList { max-height: 205px; }
|
||||
.sidebarFooter { display: none; }
|
||||
.main { width: 100%; padding: 20px 14px 55px; }
|
||||
.breadcrumb { display: none; }
|
||||
.titleGroup { grid-template-columns: 1fr; }
|
||||
.titleGroup input { width: 100%; margin-top: 8px; font-size: 20px; }
|
||||
.titleGroup p { grid-column: 1; line-height: 1.6; }
|
||||
.headerMenu { display: none; }
|
||||
.summary { grid-template-columns: repeat(2,1fr); }
|
||||
.summary > div:nth-child(2) { border-right: 0; }
|
||||
.summary > div:nth-child(-n+2) { border-bottom: 1px solid #eef0f4; }
|
||||
.sectionBar div > span { display: none; }
|
||||
.itemHeader { flex-wrap: wrap; }
|
||||
.itemHeader > div:nth-of-type(1) { min-width: 50%; }
|
||||
.itemStatus { margin-left: auto; }
|
||||
.itemActions { width: 100%; justify-content: flex-end; padding-top: 6px; border-top: 1px solid #eef0f3; }
|
||||
.itemBody { padding: 15px; }
|
||||
.fieldsGrid { grid-template-columns: 1fr; }
|
||||
.wideField, .descriptionField { grid-column: 1; }
|
||||
.imageGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.attachmentHeading small { display: none; }
|
||||
.addItemBottom small { display: none; }
|
||||
.formFooter { align-items: flex-start; gap: 15px; flex-direction: column; }
|
||||
.formFooter > div { width: 100%; }
|
||||
.formFooter button { flex: 1; }
|
||||
.lightbox { padding: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.imageGrid { grid-template-columns: 1fr; }
|
||||
.imagePreview { height: 180px; }
|
||||
}
|
||||
319
app/repeatable-form/page.tsx
Normal file
319
app/repeatable-form/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import seedData from "../data/repeatable-forms.json";
|
||||
import styles from "./RepeatableForm.module.css";
|
||||
|
||||
type ImageAttachment = { id: string; name: string; size: string; url: string };
|
||||
type FileAttachment = { id: string; name: string; size: string; type: string; url: string };
|
||||
type FormItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
owner: string;
|
||||
status: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
dueDate: string;
|
||||
description: string;
|
||||
images: ImageAttachment[];
|
||||
files: FileAttachment[];
|
||||
};
|
||||
type FormRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
code: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
tags: string[];
|
||||
items: FormItem[];
|
||||
};
|
||||
|
||||
const initialForms = seedData.forms as FormRecord[];
|
||||
const recordStatusStyles: Record<string, string> = { "编辑中": "recordEditing", "已提交": "recordSubmitted", "草稿": "recordDraft" };
|
||||
const formStatusStyles: Record<string, string> = { "编辑中": "formStatusEditing", "已提交": "formStatusSubmitted", "草稿": "formStatusDraft" };
|
||||
const itemStatusStyles: Record<string, string> = { "待填写": "itemStatusEmpty", "进行中": "itemStatusDoing", "待确认": "itemStatusReview", "已完成": "itemStatusDone" };
|
||||
|
||||
export default function RepeatableFormPage() {
|
||||
const [forms, setForms] = useState<FormRecord[]>(initialForms);
|
||||
const [activeId, setActiveId] = useState(initialForms[0].id);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("全部");
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
const [lightbox, setLightbox] = useState<ImageAttachment | null>(null);
|
||||
const [toast, setToast] = useState("");
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [storageWarning, setStorageWarning] = useState(false);
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cached = window.localStorage.getItem("orbit-repeatable-forms");
|
||||
const cachedActive = window.localStorage.getItem("orbit-repeatable-active");
|
||||
if (cached) setForms(JSON.parse(cached));
|
||||
if (cachedActive) setActiveId(cachedActive);
|
||||
} catch {
|
||||
window.localStorage.removeItem("orbit-repeatable-forms");
|
||||
}
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
setSaved(false);
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
|
||||
window.localStorage.setItem("orbit-repeatable-active", activeId);
|
||||
setSaved(true);
|
||||
setStorageWarning(false);
|
||||
} catch {
|
||||
setStorageWarning(true);
|
||||
}
|
||||
}, 550);
|
||||
return () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); };
|
||||
}, [forms, activeId, hydrated]);
|
||||
|
||||
const activeForm = forms.find((form) => form.id === activeId) ?? forms[0];
|
||||
const visibleForms = useMemo(() => forms.filter((form) => {
|
||||
const matchesQuery = `${form.title}${form.code}${form.tags.join("")}`.toLowerCase().includes(query.toLowerCase());
|
||||
return matchesQuery && (statusFilter === "全部" || form.status === statusFilter);
|
||||
}), [forms, query, statusFilter]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const items = activeForm?.items ?? [];
|
||||
return {
|
||||
completed: items.filter((item) => item.status === "已完成").length,
|
||||
images: items.reduce((sum, item) => sum + item.images.length, 0),
|
||||
files: items.reduce((sum, item) => sum + item.files.length, 0),
|
||||
};
|
||||
}, [activeForm]);
|
||||
|
||||
const notify = (message: string) => {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2200);
|
||||
};
|
||||
|
||||
const updateForm = (patch: Partial<FormRecord>) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, ...patch, updatedAt: "刚刚" } : form));
|
||||
|
||||
const updateItem = (itemId: string, patch: Partial<FormItem>) => setForms((current) => current.map((form) => form.id === activeId ? {
|
||||
...form,
|
||||
updatedAt: "刚刚",
|
||||
items: form.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
|
||||
} : form));
|
||||
|
||||
const createForm = () => {
|
||||
const id = `FORM-${Date.now()}`;
|
||||
const record: FormRecord = { id, title: "未命名资料表单", code: `DRAFT-${String(Date.now()).slice(-6)}`, status: "草稿", updatedAt: "刚刚", createdBy: "林知夏", tags: ["新表单"], items: [emptyItem()] };
|
||||
setForms((current) => [record, ...current]);
|
||||
setActiveId(id);
|
||||
notify("已创建新表单");
|
||||
};
|
||||
|
||||
const duplicateForm = () => {
|
||||
if (!activeForm) return;
|
||||
const now = Date.now();
|
||||
const duplicated: FormRecord = {
|
||||
...activeForm,
|
||||
id: `FORM-${now}`,
|
||||
title: `${activeForm.title} - 副本`,
|
||||
status: "草稿",
|
||||
updatedAt: "刚刚",
|
||||
items: activeForm.items.map((item, index) => ({ ...item, id: `ITEM-${now}-${index}` })),
|
||||
};
|
||||
setForms((current) => [duplicated, ...current]);
|
||||
setActiveId(duplicated.id);
|
||||
notify("表单副本已创建");
|
||||
};
|
||||
|
||||
const removeForm = () => {
|
||||
if (!activeForm || forms.length === 1) return notify("至少需要保留一份表单");
|
||||
const next = forms.filter((form) => form.id !== activeForm.id);
|
||||
setForms(next);
|
||||
setActiveId(next[0].id);
|
||||
notify("表单已删除");
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const item = emptyItem();
|
||||
updateForm({ items: [...activeForm.items, item] });
|
||||
window.setTimeout(() => document.getElementById(item.id)?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
|
||||
notify("已添加一条表单项");
|
||||
};
|
||||
|
||||
const duplicateItem = (item: FormItem) => {
|
||||
const clone = { ...item, id: `ITEM-${Date.now()}`, title: `${item.title} - 副本`, images: [...item.images], files: [...item.files] };
|
||||
const index = activeForm.items.findIndex((current) => current.id === item.id);
|
||||
const next = [...activeForm.items];
|
||||
next.splice(index + 1, 0, clone);
|
||||
updateForm({ items: next });
|
||||
notify("表单项已复制");
|
||||
};
|
||||
|
||||
const removeItem = (itemId: string) => {
|
||||
updateForm({ items: activeForm.items.filter((item) => item.id !== itemId) });
|
||||
notify("表单项已删除");
|
||||
};
|
||||
|
||||
const moveItem = (itemId: string, direction: -1 | 1) => {
|
||||
const index = activeForm.items.findIndex((item) => item.id === itemId);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= activeForm.items.length) return;
|
||||
const next = [...activeForm.items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
updateForm({ items: next });
|
||||
};
|
||||
|
||||
const handleAttachments = async (itemId: string, files: FileList | null, kind: "images" | "files") => {
|
||||
if (!files?.length) return;
|
||||
const item = activeForm.items.find((current) => current.id === itemId);
|
||||
if (!item) return;
|
||||
const limit = kind === "images" ? 5 : 6;
|
||||
const currentCount = item[kind].length;
|
||||
const incoming = Array.from(files).slice(0, Math.max(0, limit - currentCount));
|
||||
if (incoming.length === 0) return notify(`每条最多添加 ${limit} 个${kind === "images" ? "图片" : "文件"}`);
|
||||
|
||||
const maxBytes = kind === "images" ? 1_500_000 : 1_000_000;
|
||||
const allowed = incoming.filter((file) => file.size <= maxBytes && (kind === "files" || file.type.startsWith("image/")));
|
||||
if (allowed.length !== incoming.length) notify(`已跳过超过限制的${kind === "images" ? "图片(1.5MB)" : "文件(1MB)"}`);
|
||||
const encoded = await Promise.all(allowed.map(async (file, index) => {
|
||||
const url = await readAsDataUrl(file);
|
||||
if (kind === "images") return { id: `IMG-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), url } as ImageAttachment;
|
||||
const extension = file.name.split(".").pop()?.toUpperCase() || "FILE";
|
||||
return { id: `FILE-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), type: extension, url } as FileAttachment;
|
||||
}));
|
||||
updateItem(itemId, kind === "images" ? { images: [...item.images, ...(encoded as ImageAttachment[])] } : { files: [...item.files, ...(encoded as FileAttachment[])] });
|
||||
notify(`已添加 ${encoded.length} 个${kind === "images" ? "图片" : "文件"}`);
|
||||
};
|
||||
|
||||
const saveNow = () => {
|
||||
try {
|
||||
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
|
||||
setSaved(true);
|
||||
setStorageWarning(false);
|
||||
notify("整份表单已保存到当前浏览器");
|
||||
} catch {
|
||||
setStorageWarning(true);
|
||||
notify("存储空间不足,请删除较大的附件后重试");
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeForm) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.topbar}>
|
||||
<Link href="/" className={styles.brand}><span>F</span><strong>FormStack</strong></Link>
|
||||
<nav><Link href="/simple-list">列表模板</Link><Link className={styles.activeNav} href="/repeatable-form">可重复表单</Link><Link href="/markdown">文档编辑</Link></nav>
|
||||
<div className={styles.saveState}><i className={saved ? styles.savedDot : ""} />{saved ? "所有更改已保存" : "正在自动保存…"}</div>
|
||||
<div className={styles.headerActions}><button onClick={() => notify("预览模式为模板交互占位")}>预览</button><button className={styles.saveButton} onClick={saveNow}>保存表单</button><span>林</span></div>
|
||||
</header>
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sidebarTitle}><div><p>FORM RECORDS</p><strong>表单列表</strong></div><button onClick={createForm}>+</button></div>
|
||||
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称或编号" /></label>
|
||||
<div className={styles.statusTabs}>{["全部", "编辑中", "已提交", "草稿"].map((value) => <button key={value} className={statusFilter === value ? styles.statusTabActive : ""} onClick={() => setStatusFilter(value)}>{value}</button>)}</div>
|
||||
<div className={styles.formList}>
|
||||
{visibleForms.map((form) => <button key={form.id} className={form.id === activeId ? styles.formActive : ""} onClick={() => setActiveId(form.id)}>
|
||||
<span className={styles.formIcon}>▤</span><div><strong>{form.title}</strong><small>{form.code}</small><p><i className={styles[recordStatusStyles[form.status]]} />{form.status}<span>{form.items.length} 项</span><time>{form.updatedAt}</time></p></div>
|
||||
</button>)}
|
||||
{visibleForms.length === 0 && <div className={styles.noForms}>没有匹配表单</div>}
|
||||
</div>
|
||||
<div className={styles.sidebarFooter}><button onClick={duplicateForm}>▣ 复制当前表单</button><button onClick={removeForm}>⌫ 删除当前表单</button><p>本地演示模式<br /><span>数据仅保存在当前设备</span></p></div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.breadcrumb}><Link href="/">模板首页</Link><span>/</span><span>可重复表单</span><span>/</span><strong>{activeForm.code}</strong></div>
|
||||
<section className={styles.formHeader}>
|
||||
<div className={styles.titleGroup}><span className={`${styles.formStatus} ${styles[formStatusStyles[activeForm.status]]}`}>{activeForm.status}</span><input value={activeForm.title} onChange={(event) => updateForm({ title: event.target.value })} aria-label="表单名称" /><p>编号 {activeForm.code} · 创建人 {activeForm.createdBy} · 更新于 {activeForm.updatedAt}</p></div>
|
||||
<div className={styles.headerMenu}><button onClick={duplicateForm}>复制</button><button onClick={() => notify("更多操作:导出 JSON、打印、归档")}>•••</button></div>
|
||||
</section>
|
||||
|
||||
<section className={styles.summary}>
|
||||
<div><span>表单项</span><strong>{activeForm.items.length}</strong><small>可继续添加</small></div>
|
||||
<div><span>已完成</span><strong>{totals.completed}<em> / {activeForm.items.length}</em></strong><small>{activeForm.items.length ? Math.round(totals.completed / activeForm.items.length * 100) : 0}% 完成</small></div>
|
||||
<div><span>图片附件</span><strong>{totals.images}</strong><small>支持预览</small></div>
|
||||
<div><span>文档附件</span><strong>{totals.files}</strong><small>支持下载</small></div>
|
||||
</section>
|
||||
|
||||
{storageWarning && <div className={styles.warning}><span>!</span><p><strong>浏览器存储空间不足</strong>请删除较大的图片或文件。此纯前端模板会将新附件保存为本地数据。</p><button onClick={() => setStorageWarning(false)}>×</button></div>}
|
||||
|
||||
<div className={styles.sectionBar}><div><p>REPEATABLE ITEMS</p><h2>表单明细</h2><span>每条明细可独立添加图片和文件附件。</span></div><button onClick={addItem}>+ 添加表单项</button></div>
|
||||
|
||||
<div className={styles.itemList}>
|
||||
{activeForm.items.map((item, index) => {
|
||||
const isCollapsed = collapsed.includes(item.id);
|
||||
return <article className={styles.itemCard} id={item.id} key={item.id}>
|
||||
<header className={styles.itemHeader}>
|
||||
<span className={styles.dragHandle}>⋮⋮</span><span className={styles.itemNumber}>{String(index + 1).padStart(2, "0")}</span>
|
||||
<div><strong>{item.title || "未命名表单项"}</strong><small>{item.category} · {item.images.length} 张图片 · {item.files.length} 个文件</small></div>
|
||||
<span className={`${styles.itemStatus} ${styles[itemStatusStyles[item.status]]}`}>{item.status}</span>
|
||||
<div className={styles.itemActions}><button disabled={index === 0} onClick={() => moveItem(item.id, -1)}>↑</button><button disabled={index === activeForm.items.length - 1} onClick={() => moveItem(item.id, 1)}>↓</button><button onClick={() => duplicateItem(item)}>复制</button><button onClick={() => setCollapsed((current) => isCollapsed ? current.filter((id) => id !== item.id) : [...current, item.id])}>{isCollapsed ? "展开" : "收起"}</button><button className={styles.deleteAction} onClick={() => removeItem(item.id)}>删除</button></div>
|
||||
</header>
|
||||
{!isCollapsed && <div className={styles.itemBody}>
|
||||
<div className={styles.fieldsGrid}>
|
||||
<label className={styles.wideField}><span>明细名称 <b>*</b></span><input value={item.title} onChange={(event) => updateItem(item.id, { title: event.target.value })} placeholder="请输入明细名称" /></label>
|
||||
<label><span>分类</span><select value={item.category} onChange={(event) => updateItem(item.id, { category: event.target.value })}><option>页面交付</option><option>说明文档</option><option>设计素材</option><option>巡检项目</option><option>采购物料</option><option>其他</option></select></label>
|
||||
<label><span>负责人</span><input value={item.owner} onChange={(event) => updateItem(item.id, { owner: event.target.value })} /></label>
|
||||
<label><span>状态</span><select value={item.status} onChange={(event) => updateItem(item.id, { status: event.target.value })}><option>待填写</option><option>进行中</option><option>待确认</option><option>已完成</option></select></label>
|
||||
<label><span>数量</span><input type="number" min="0" value={item.quantity} onChange={(event) => updateItem(item.id, { quantity: Number(event.target.value) })} /></label>
|
||||
<label><span>单位</span><input value={item.unit} onChange={(event) => updateItem(item.id, { unit: event.target.value })} /></label>
|
||||
<label><span>计划日期</span><input type="date" value={item.dueDate} onChange={(event) => updateItem(item.id, { dueDate: event.target.value })} /></label>
|
||||
<label className={styles.descriptionField}><span>说明</span><textarea value={item.description} onChange={(event) => updateItem(item.id, { description: event.target.value })} placeholder="补充本条明细的内容、规格或验收要求" /></label>
|
||||
</div>
|
||||
|
||||
<div className={styles.attachmentSection}>
|
||||
<div className={styles.attachmentHeading}><div><span className={styles.imageBadge}>▧</span><p><strong>图片列表</strong><small>JPG、PNG、WEBP · 新上传单张不超过 1.5MB · 最多 5 张</small></p></div><label className={styles.uploadButton}>+ 添加图片<input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label></div>
|
||||
<div className={styles.imageGrid}>
|
||||
{item.images.map((image) => <figure key={image.id}><button className={styles.imagePreview} onClick={() => setLightbox(image)}><img src={image.url} alt={image.name} /><span>预览</span></button><figcaption><p><strong>{image.name}</strong><small>{image.size}</small></p><button aria-label={`删除 ${image.name}`} onClick={() => updateItem(item.id, { images: item.images.filter((current) => current.id !== image.id) })}>×</button></figcaption></figure>)}
|
||||
{item.images.length === 0 && <label className={styles.emptyImage}><span>+</span><strong>添加现场或成果图片</strong><small>支持一次选择多张</small><input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.attachmentSection}>
|
||||
<div className={styles.attachmentHeading}><div><span className={styles.fileBadge}>⌑</span><p><strong>文件列表</strong><small>文档、表格、压缩包等 · 新上传单个不超过 1MB · 最多 6 个</small></p></div><label className={styles.uploadButton}>+ 添加文件<input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label></div>
|
||||
<div className={styles.fileList}>
|
||||
{item.files.map((file) => <div key={file.id}><span className={styles.fileType}>{file.type}</span><p><strong>{file.name}</strong><small>{file.size} · 添加于刚刚</small></p>{file.url ? <a href={file.url} download={file.name}>下载</a> : <button onClick={() => notify("这是 JSON 示例文件,仅展示文件信息")}>查看</button>}<button aria-label={`删除 ${file.name}`} onClick={() => updateItem(item.id, { files: item.files.filter((current) => current.id !== file.id) })}>×</button></div>)}
|
||||
{item.files.length === 0 && <label className={styles.emptyFile}>+ 点击添加相关文件<input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label>}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</article>;
|
||||
})}
|
||||
{activeForm.items.length === 0 && <div className={styles.emptyItems}><span>▤</span><h3>还没有表单项</h3><p>添加第一条明细,并为它上传图片或文件。</p><button onClick={addItem}>+ 添加第一项</button></div>}
|
||||
</div>
|
||||
|
||||
<button className={styles.addItemBottom} onClick={addItem}><span>+</span><strong>继续添加一条表单项</strong><small>字段、图片和文件列表将独立保存</small></button>
|
||||
<footer className={styles.formFooter}><p><i className={saved ? styles.savedDot : ""} />{saved ? `上次保存:${activeForm.updatedAt}` : "存在未保存更改"}</p><div><button onClick={() => updateForm({ status: "草稿" })}>保存为草稿</button><button className={styles.submitButton} onClick={() => { updateForm({ status: "已提交" }); saveNow(); notify("表单已提交(演示)"); }}>提交表单</button></div></footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{lightbox && <div className={styles.lightbox} onMouseDown={() => setLightbox(null)}><div onMouseDown={(event) => event.stopPropagation()}><header><p><strong>{lightbox.name}</strong><small>{lightbox.size}</small></p><button onClick={() => setLightbox(null)}>×</button></header><img src={lightbox.url} alt={lightbox.name} /></div></div>}
|
||||
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyItem(): FormItem {
|
||||
return { id: `ITEM-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, title: "", category: "页面交付", owner: "林知夏", status: "待填写", quantity: 1, unit: "项", dueDate: new Date().toISOString().slice(0, 10), description: "", images: [], files: [] };
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user