更新
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"total": 0,
|
||||
"total": 1,
|
||||
"byTemplate": {
|
||||
"lobster-birth": 1
|
||||
"lobster-birth": 2
|
||||
},
|
||||
"updatedAt": "2026-03-17T09:44:46.005Z"
|
||||
"updatedAt": "2026-03-18T03:21:37.604Z"
|
||||
}
|
||||
685
src/index.css
685
src/index.css
@@ -41,6 +41,10 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -62,13 +66,6 @@ code {
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.32));
|
||||
}
|
||||
|
||||
.app-shell--empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #111827;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.page {
|
||||
margin: 0 auto;
|
||||
max-width: 1440px;
|
||||
@@ -79,65 +76,6 @@ code {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.studio-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(244, 247, 255, 0.88));
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.studio-kicker,
|
||||
.section-kicker {
|
||||
margin: 0 0 8px;
|
||||
color: var(--page-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.studio-header h1,
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.studio-header h1 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
}
|
||||
|
||||
.studio-copy,
|
||||
.panel-head p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.studio-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.studio-summary span {
|
||||
border: 1px solid rgba(91, 108, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 108, 255, 0.08);
|
||||
padding: 10px 14px;
|
||||
color: var(--page-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 28px;
|
||||
@@ -168,7 +106,7 @@ code {
|
||||
|
||||
.panel-head--inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -178,7 +116,31 @@ code {
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.template-strip__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.template-strip__summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 108, 255, 0.08);
|
||||
padding: 0 12px;
|
||||
color: var(--page-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel--templates {
|
||||
@@ -186,13 +148,22 @@ code {
|
||||
}
|
||||
|
||||
.template-strip {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.template-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
flex: 0 0 min(280px, 84vw);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 24px;
|
||||
@@ -200,6 +171,7 @@ code {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
scroll-snap-align: start;
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
@@ -219,7 +191,6 @@ code {
|
||||
|
||||
.template-card__preview {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 5;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #e9eeff, #f8faff);
|
||||
}
|
||||
@@ -232,17 +203,18 @@ code {
|
||||
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.16));
|
||||
}
|
||||
|
||||
.template-card__preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
.template-card__preview--component {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #e9eeff, #f8faff);
|
||||
}
|
||||
|
||||
.template-card__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
@@ -274,6 +246,50 @@ code {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.template-card__preview-shell {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.template-strip__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.template-strip__dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-strip__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 108, 255, 0.18);
|
||||
padding: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(91, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.template-strip__dot.is-active {
|
||||
width: 24px;
|
||||
background: linear-gradient(135deg, var(--page-accent), #7f8dff);
|
||||
}
|
||||
|
||||
.template-strip__position {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace--studio {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
@@ -290,6 +306,10 @@ code {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid--mobile {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -353,29 +373,16 @@ code {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-status span {
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 108, 255, 0.08);
|
||||
padding: 8px 12px;
|
||||
color: var(--page-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-frame--studio {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
border-radius: 26px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(91, 108, 255, 0.08), transparent 26%),
|
||||
@@ -384,88 +391,72 @@ code {
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: #f9fafb;
|
||||
box-shadow: 0 24px 44px rgba(64, 78, 150, 0.18);
|
||||
}
|
||||
|
||||
.preview-background,
|
||||
.preview-text,
|
||||
.preview-qr,
|
||||
.preview-stats {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.preview-background {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-qr {
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 8px;
|
||||
box-shadow: 0 14px 26px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 12px 20px;
|
||||
color: var(--page-rose);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.download-row {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.download-row button {
|
||||
min-width: 148px;
|
||||
border: 0;
|
||||
.download-row button,
|
||||
.mobile-preview-actions__primary,
|
||||
.mobile-preview-actions__secondary,
|
||||
.mobile-form-sheet__back,
|
||||
.mobile-form-sheet__close,
|
||||
.mobile-form-sheet__done {
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
filter 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.download-row button,
|
||||
.mobile-preview-actions__primary,
|
||||
.mobile-form-sheet__done {
|
||||
min-width: 148px;
|
||||
background: linear-gradient(135deg, var(--page-accent), var(--page-rose));
|
||||
padding: 12px 24px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 14px 30px rgba(91, 108, 255, 0.24);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
}
|
||||
|
||||
.download-row button:hover:enabled {
|
||||
.mobile-preview-actions__secondary,
|
||||
.mobile-form-sheet__back,
|
||||
.mobile-form-sheet__close {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text-main);
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
}
|
||||
|
||||
.download-row button:hover:enabled,
|
||||
.mobile-preview-actions__primary:hover:enabled,
|
||||
.mobile-preview-actions__secondary:hover,
|
||||
.mobile-form-sheet__back:hover,
|
||||
.mobile-form-sheet__close:hover,
|
||||
.mobile-form-sheet__done:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.3);
|
||||
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.24);
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
.download-row button:disabled {
|
||||
.download-row button:disabled,
|
||||
.mobile-preview-actions__primary:disabled,
|
||||
.mobile-form-sheet__done:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-preview-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -476,128 +467,11 @@ code {
|
||||
padding: 12px 14px;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.studio-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.panel-head--inline {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.template-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.workspace--studio {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: #dbe4ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.preview-canvas--tx {
|
||||
background: linear-gradient(180deg, #edf3ff 0%, #dbe7ff 100%);
|
||||
}
|
||||
|
||||
.preview-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-background--cover {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-text--tx {
|
||||
text-shadow: 0 4px 18px rgba(5, 21, 66, 0.12);
|
||||
}
|
||||
|
||||
.preview-text--tx-count {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.preview-text--layer {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.preview-qr {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-qr--tx {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
margin-top: 14px;
|
||||
display: inline-flex;
|
||||
align-self: flex-end;
|
||||
border: 1px solid rgba(99, 102, 241, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 10px 14px;
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.template-card__preview--component {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: auto;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #e9eeff, #f8faff);
|
||||
}
|
||||
|
||||
.template-card__preview-shell {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-frame--studio {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.mobile-form-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.export-stage {
|
||||
@@ -608,3 +482,272 @@ code {
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.panel-head--inline {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.template-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.workspace--studio {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.app-shell {
|
||||
padding: 14px 12px 24px;
|
||||
}
|
||||
|
||||
.page--studio {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.panel--templates {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
flex: 0 0 72vw;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.template-card__preview--component {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.template-card__body {
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
.template-card__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.template-card__tag {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.template-strip__summary {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.template-strip__summary span:last-child {
|
||||
color: #7c89c0;
|
||||
background: rgba(124, 137, 192, 0.1);
|
||||
}
|
||||
|
||||
.template-strip {
|
||||
padding-right: 18vw;
|
||||
}
|
||||
|
||||
.template-strip__footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.panel--fields {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-frame--studio {
|
||||
padding: 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-preview-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
|
||||
}
|
||||
|
||||
.mobile-preview-actions__primary,
|
||||
.mobile-preview-actions__secondary {
|
||||
min-height: 52px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-form-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
opacity: 0;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #f9fbff 0%, #eef3ff 100%);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.22s ease;
|
||||
box-shadow: 0 -18px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.mobile-form-sheet.is-open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobile-form-sheet.is-open .mobile-form-sheet__backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-form-sheet.is-open .mobile-form-sheet__panel {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mobile-form-sheet__header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.mobile-form-sheet__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__back,
|
||||
.mobile-form-sheet__close {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
|
||||
.form-grid--mobile .field {
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.form-grid--mobile .field input,
|
||||
.form-grid--mobile .field textarea {
|
||||
min-height: 52px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.form-grid--mobile .field textarea {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__footer {
|
||||
padding: 14px 16px calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.mobile-form-sheet__done {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.app-shell {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.panel--templates {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
flex-basis: 82vw;
|
||||
}
|
||||
|
||||
.template-strip {
|
||||
padding-right: 20vw;
|
||||
}
|
||||
|
||||
.template-strip__summary {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-strip__summary span,
|
||||
.template-strip__position {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview-frame--studio {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mobile-preview-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-form-sheet__header,
|
||||
.mobile-form-sheet__body,
|
||||
.mobile-form-sheet__footer {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const certificateTemplates: CertificateTemplateDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "lobster-birth-tx",
|
||||
name: "小龙虾出生证明腾讯云版",
|
||||
name: "小龙虾出生证明",
|
||||
width: 1576,
|
||||
height: 1080,
|
||||
Component: LobsterBirthTxTemplate,
|
||||
|
||||
@@ -30,7 +30,8 @@ const emptyStats: StatsResponse = {
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const previewWidth = 760;
|
||||
const desktopPreviewWidth = 760;
|
||||
const mobileBreakpoint = 768;
|
||||
const statsUnavailableMessage =
|
||||
"统计服务未连接,当前数量不会持久化。请同时运行 npm run dev 和 npm run dev:server,或直接运行 npm run dev:all。";
|
||||
const statsIncrementFailedMessage =
|
||||
@@ -59,6 +60,94 @@ function TemplateThumbnail({
|
||||
);
|
||||
}
|
||||
|
||||
function useIsMobile(breakpoint: number) {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.innerWidth < breakpoint;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const sync = () => setIsMobile(mediaQuery.matches);
|
||||
|
||||
sync();
|
||||
mediaQuery.addEventListener("change", sync);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", sync);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
function useContainerWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateWidth = () => {
|
||||
setWidth(node.clientWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateWidth();
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
function CertificateFormFields({
|
||||
formData,
|
||||
onChange,
|
||||
className = "form-grid",
|
||||
}: {
|
||||
formData: CertificateFormData;
|
||||
onChange: (key: CertificateFieldKey) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{certificateFieldOrder.map((key) => (
|
||||
<label key={key} className="field">
|
||||
<span>{certificateFieldLabels[key]}</span>
|
||||
{key === "familyAddress" || key === "birthAddress" ? (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData[key]}
|
||||
onChange={onChange(key)}
|
||||
placeholder={`请输入${certificateFieldLabels[key]}`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={formData[key]}
|
||||
onChange={onChange(key)}
|
||||
placeholder={`请输入${certificateFieldLabels[key]}`}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CertificateGenerator() {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(
|
||||
certificateTemplates[0]?.id ?? "",
|
||||
@@ -68,7 +157,10 @@ export function CertificateGenerator() {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [statsMessage, setStatsMessage] = useState("");
|
||||
const [isMobileFormOpen, setIsMobileFormOpen] = useState(false);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
const [previewMeasureRef, previewContainerWidth] = useContainerWidth<HTMLDivElement>();
|
||||
const isMobile = useIsMobile(mobileBreakpoint);
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() =>
|
||||
@@ -80,6 +172,20 @@ export function CertificateGenerator() {
|
||||
const qrSource = useMemo(() => getQrSource(), []);
|
||||
const currentCount = stats.total + 1;
|
||||
const SelectedTemplateComponent = selectedTemplate.Component;
|
||||
const selectedTemplateIndex = Math.max(
|
||||
certificateTemplates.findIndex((template) => template.id === selectedTemplate.id),
|
||||
0,
|
||||
);
|
||||
|
||||
const previewScale = useMemo(() => {
|
||||
const fallbackWidth = isMobile ? 320 : desktopPreviewWidth;
|
||||
const availableWidth = previewContainerWidth || fallbackWidth;
|
||||
const targetWidth = isMobile
|
||||
? Math.max(availableWidth, 280)
|
||||
: Math.min(availableWidth, desktopPreviewWidth);
|
||||
|
||||
return Math.min(targetWidth / selectedTemplate.width, 1);
|
||||
}, [isMobile, previewContainerWidth, selectedTemplate.width]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -100,6 +206,29 @@ export function CertificateGenerator() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setIsMobileFormOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { body } = document;
|
||||
const previousOverflow = body.style.overflow;
|
||||
|
||||
if (isMobile && isMobileFormOpen) {
|
||||
body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [isMobile, isMobileFormOpen]);
|
||||
|
||||
const handleChange =
|
||||
(key: CertificateFieldKey) =>
|
||||
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
@@ -154,6 +283,9 @@ export function CertificateGenerator() {
|
||||
<div>
|
||||
<h2>模板切换</h2>
|
||||
</div>
|
||||
<div className="template-strip__summary">
|
||||
<span>{`共 ${certificateTemplates.length} 个模板`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-strip">
|
||||
{certificateTemplates.map((template) => {
|
||||
@@ -177,7 +309,6 @@ export function CertificateGenerator() {
|
||||
<div className="template-card__body">
|
||||
<div>
|
||||
<p className="template-card__title">{template.name}</p>
|
||||
<p className="template-card__meta">{template.width} x {template.height}</p>
|
||||
</div>
|
||||
<span className="template-card__tag">{active ? "当前模板" : "点击切换"}</span>
|
||||
</div>
|
||||
@@ -185,6 +316,19 @@ export function CertificateGenerator() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="template-strip__footer">
|
||||
<div className="template-strip__dots" aria-label="模板位置">
|
||||
{certificateTemplates.map((template, index) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={`template-strip__dot ${index === selectedTemplateIndex ? "is-active" : ""}`}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
aria-label={`切换到模板 ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="workspace workspace--studio">
|
||||
@@ -192,27 +336,7 @@ export function CertificateGenerator() {
|
||||
<div className="panel-head">
|
||||
<h2>龙虾信息填写</h2>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
{certificateFieldOrder.map((key) => (
|
||||
<label key={key} className="field">
|
||||
<span>{certificateFieldLabels[key]}</span>
|
||||
{key === "familyAddress" || key === "birthAddress" ? (
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData[key]}
|
||||
onChange={handleChange(key)}
|
||||
placeholder={`请输入${certificateFieldLabels[key]}`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={formData[key]}
|
||||
onChange={handleChange(key)}
|
||||
placeholder={`请输入${certificateFieldLabels[key]}`}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<CertificateFormFields formData={formData} onChange={handleChange} />
|
||||
|
||||
{customTemplateUploadEnabled ? (
|
||||
<div className="panel-note">
|
||||
@@ -231,11 +355,11 @@ export function CertificateGenerator() {
|
||||
{errorMessage ? <div className="error-box">{errorMessage}</div> : null}
|
||||
{statsMessage ? <div className="error-box">{statsMessage}</div> : null}
|
||||
|
||||
<div className="preview-frame preview-frame--studio">
|
||||
<div ref={previewMeasureRef} className="preview-frame preview-frame--studio">
|
||||
<TemplateScaleFrame
|
||||
width={selectedTemplate.width}
|
||||
height={selectedTemplate.height}
|
||||
scale={previewWidth / selectedTemplate.width}
|
||||
scale={previewScale}
|
||||
>
|
||||
<SelectedTemplateComponent
|
||||
formData={formData}
|
||||
@@ -250,9 +374,48 @@ export function CertificateGenerator() {
|
||||
{isExporting ? "导出中..." : "下载图片"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-preview-actions">
|
||||
<button type="button" className="mobile-preview-actions__primary" onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting ? "导出中..." : "下载图片"}
|
||||
</button>
|
||||
<button type="button" className="mobile-preview-actions__secondary" onClick={() => setIsMobileFormOpen(true)}>
|
||||
填写信息
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{isMobile ? (
|
||||
<div className={`mobile-form-sheet ${isMobileFormOpen ? "is-open" : ""}`} aria-hidden={!isMobileFormOpen}>
|
||||
<div className="mobile-form-sheet__backdrop" onClick={() => setIsMobileFormOpen(false)} />
|
||||
<div className="mobile-form-sheet__panel" role="dialog" aria-modal="true" aria-labelledby="mobile-form-title">
|
||||
<div className="mobile-form-sheet__header">
|
||||
<button type="button" className="mobile-form-sheet__back" onClick={() => setIsMobileFormOpen(false)}>
|
||||
返回预览
|
||||
</button>
|
||||
<h2 id="mobile-form-title">填写龙虾信息</h2>
|
||||
<button type="button" className="mobile-form-sheet__close" onClick={() => setIsMobileFormOpen(false)} aria-label="关闭表单">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-form-sheet__body">
|
||||
<CertificateFormFields formData={formData} onChange={handleChange} className="form-grid form-grid--mobile" />
|
||||
{customTemplateUploadEnabled ? (
|
||||
<div className="panel-note">
|
||||
已开启 <code>VITE_ENABLE_CUSTOM_TEMPLATE_UPLOAD</code>,当前版本仅预留入口。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-form-sheet__footer">
|
||||
<button type="button" className="mobile-form-sheet__done" onClick={() => setIsMobileFormOpen(false)}>
|
||||
返回预览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="export-stage" aria-hidden="true">
|
||||
<div ref={exportRef}>
|
||||
<SelectedTemplateComponent
|
||||
|
||||
Reference in New Issue
Block a user