This commit is contained in:
root
2026-07-13 13:02:23 +00:00
parent ce111431c3
commit 2445995172
111 changed files with 11646 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Refuse to install package versions published less than 7 days (10080 minutes)
# ago, reducing exposure to freshly compromised or malicious releases.
# Honoured by pnpm and bun; npm currently has no native support and ignores it,
# so this is harmless today and forward-compatible.
minimum-release-age=10080
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
@@ -0,0 +1,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function Donate(name: string, amount: number, message: string): $CancellablePromise<$models.CampaignStats> {
return $Call.ByID(3212591404, name, amount, message);
}
export function GetRecentDonations(): $CancellablePromise<$models.Donation[] | null> {
return $Call.ByID(2710704353);
}
export function GetStats(): $CancellablePromise<$models.CampaignStats> {
return $Call.ByID(1236111792);
}
@@ -0,0 +1,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
export function Greet(name: string): $CancellablePromise<string> {
return $Call.ByID(1411160069, name);
}
+14
View File
@@ -0,0 +1,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as CampaignService from "./campaignservice.js";
import * as GreetService from "./greetservice.js";
export {
CampaignService,
GreetService
};
export type {
CampaignStats,
Donation
} from "./models.js";
+33
View File
@@ -0,0 +1,33 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../time/models.js";
export interface CampaignStats {
"totalDonors": number;
"totalAmount": number;
/**
* 마리
*/
"releasedFish": number;
/**
* kg
*/
"collectedWaste": number;
/**
* m²
*/
"purifiedArea": number;
}
export interface Donation {
"name": string;
"amount": number;
"message": string;
"timestamp": time$0.Time;
}
@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -0,0 +1,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type { Events } from "@wailsio/runtime";
declare module "@wailsio/runtime" {
namespace Events {
interface CustomEvents {
"time": string;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export type {
Time
} from "./models.js";
+47
View File
@@ -0,0 +1,47 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* A Time represents an instant in time with nanosecond precision.
*
* Programs using times should typically store and pass them as values,
* not pointers. That is, time variables and struct fields should be of
* type [time.Time], not *time.Time.
*
* A Time value can be used by multiple goroutines simultaneously except
* that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
* [Time.UnmarshalText] are not concurrency-safe.
*
* Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
* The [Time.Sub] method subtracts two instants, producing a [Duration].
* The [Time.Add] method adds a Time and a Duration, producing a Time.
*
* The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
* As this time is unlikely to come up in practice, the [Time.IsZero] method gives
* a simple way of detecting a time that has not been initialized explicitly.
*
* Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
* Time with a specific Location. Changing the Location of a Time value with
* these methods does not change the actual instant it represents, only the time
* zone in which to interpret it.
*
* Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
* [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
* but not the location name. They therefore lose information about Daylight Saving Time.
*
* In addition to the required “wall clock” reading, a Time may contain an optional
* reading of the current process's monotonic clock, to provide additional precision
* for comparison or subtraction.
* See the “Monotonic Clocks” section in the package documentation for details.
*
* Note that the Go == operator compares not just the time instant but also the
* Location and the monotonic clock reading. Therefore, Time values should not
* be used as map or database keys without first guaranteeing that the
* identical Location has been set for all values, which can be achieved
* through use of the UTC or Local method, and that the monotonic clock reading
* has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
* to t == u, since t.Equal uses the most accurate comparison available and
* correctly handles the case when only one of its arguments has a monotonic
* clock reading.
*/
export type Time = any;
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/wails.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="stylesheet" href="/style.css" />
<title>Wails + Vue + TS</title>
</head>
<body>
<div class="bg" aria-hidden="true"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1351
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "vue-tsc && vite build --minify false --mode development",
"build": "vue-tsc && vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "latest",
"lucide-vue-next": "^1.0.0",
"vue": "^3.2.45",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^4.9.3",
"vite": "^8.0.5",
"vue-tsc": "^1.0.11"
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+368
View File
@@ -0,0 +1,368 @@
:root {
/* Neon-night design tokens. */
--ink: #06070f; /* darkest base behind the image */
--text: #f4f6fb;
--muted: #9aa6c0;
--accent: #ff4d4d; /* Wails red */
--accent-2: #ff2d72; /* pink, used in gradients */
--glass: rgba(255, 255, 255, 0.05);
--glass-strong: rgba(255, 255, 255, 0.08);
--glass-border: rgba(255, 255, 255, 0.12);
--radius: 11px;
--grad: linear-gradient(135deg, #ff5a4d 0%, #ff2d72 100%);
/* Golden-ratio spacing scale (φ ≈ 1.618), used for the vertical rhythm. */
--s-1: 0.618rem; /* φ⁻¹ */
--s-2: 1rem;
--s-3: 1.618rem; /* φ */
--s-4: 2.618rem; /* φ² */
--s-5: 4.236rem; /* φ³ */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* { box-sizing: border-box; }
html {
background: var(--ink);
}
html, body {
margin: 0;
color: var(--text);
}
body {
overflow-x: hidden;
/* Nothing in the chrome is selectable (it's an app, not a document); the
text input opts back in below. */
-webkit-user-select: none;
user-select: none;
/* Drag the window by its background. Interactive controls opt out with
--wails-draggable: no-drag (the property inherits). */
--wails-draggable: drag;
}
/* Framework mount nodes (#app / #root) shouldn't affect layout, so the
body flex centering applies directly to .container. */
#app, #root { display: contents; }
/* Interactive elements: clickable (not draggable) and, for the input, selectable. */
.brand-mark, .brand-badge, .input-box, .btn, .footer-docs, .toast {
--wails-draggable: no-drag;
}
.input, input[type="text"] {
-webkit-user-select: text;
user-select: text;
}
/* ----- Background layer ----------------------------------------------------
A fixed, full-viewport layer rather than `background-attachment: fixed`
(which iOS WKWebView ignores / renders janky). A dark gradient is layered
over the photo so foreground text stays legible. On desktop the gradient is
weighted to the left, where the content sits. */
.bg {
position: fixed;
inset: 0;
z-index: -1;
background:
linear-gradient(90deg, rgba(6, 7, 15, 0.94) 0%, rgba(6, 7, 15, 0.78) 32%,
rgba(6, 7, 15, 0.42) 64%, rgba(6, 7, 15, 0.30) 100%),
linear-gradient(0deg, rgba(6, 7, 15, 0.70) 0%, rgba(6, 7, 15, 0.10) 45%),
url("/bg-desktop.jpg") center / cover no-repeat;
}
body {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column; /* content area + footer pinned to the bottom */
/* Respect mobile safe areas (notch / home indicator). No bottom padding:
the footer sits flush at the bottom edge. */
/* Wider outer margins on the golden scale (φ² top/right, φ³ left). */
padding: max(var(--s-4), env(safe-area-inset-top)) max(var(--s-4), env(safe-area-inset-right))
0 max(var(--s-5), env(safe-area-inset-left));
}
.container {
flex: 1 0 auto; /* fill the space above the footer */
width: 100%;
max-width: 540px;
display: flex;
flex-direction: column;
justify-content: center; /* vertically centre the content */
align-items: flex-start;
text-align: left;
}
/* ----- Brand row ----------------------------------------------------------- */
.brand {
display: flex;
align-items: center;
gap: var(--s-2);
/* No top margin — the content block is vertically centred, so an extra top
margin would push everything (incl. the input) down. φ² gap below. */
margin-bottom: var(--s-4);
}
.brand-mark, .brand-badge {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, filter 0.2s ease;
}
.brand-mark:hover, .brand-badge:hover {
transform: translateY(-2px);
filter: drop-shadow(0 0 1em rgba(255, 77, 77, 0.45));
}
/* Logo heights sit on the golden scale (φ³ = 4.236rem). The Wails mark carries
its wordmark, so the badge is one golden step smaller (φ³ ÷ √φ) to keep the
pair visually balanced. */
.brand-logo { height: var(--s-5); width: auto; display: block; object-fit: contain; }
.brand-badge img { height: calc(var(--s-5) / 1.272); width: auto; display: block; object-fit: contain; }
/* ----- Version badge ------------------------------------------------------- */
.badge {
display: inline-block;
padding: 4px 12px;
margin-bottom: 1.1rem;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: #ff7a8f;
background: rgba(255, 45, 114, 0.14);
border: 1px solid rgba(255, 45, 114, 0.32);
border-radius: 999px;
}
/* ----- Heading ------------------------------------------------------------- */
.title {
margin: 0 0 var(--s-2);
max-width: 100%;
font-size: clamp(2.5rem, 7.5vw, 3.7rem);
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text);
overflow-wrap: break-word;
}
.title-accent {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* The framework word ("JavaScript") crossfades to the entered name on Greet:
the outgoing word is taken out of flow and fades out while the incoming word
fades in over the same spot. */
.title-name { position: relative; display: inline-block; }
.title-name-text {
display: inline-block;
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 0.68, 0.2, 1);
}
.title-name-text.is-outgoing { position: absolute; left: 0; top: 0; }
.title-name-text.is-entering { opacity: 0; transform: translateY(0.3em); } /* rises in from below */
.title-name-text.is-leaving { opacity: 0; transform: translateY(-0.3em); } /* lifts out upward */
.subtitle {
margin: 0 0 var(--s-5);
max-width: 32rem;
color: var(--muted);
font-size: clamp(1.05rem, 2.9vw, 1.2rem);
line-height: 1.55;
}
/* ----- Greet input + button ------------------------------------------------ */
/* Input row and the card below it are sized down so they sit more compact
under the heading. */
.greet { width: 62%; }
/* Input font is pinned at 16px: iOS Safari/WKWebView zooms the page when an
input under 16px gains focus, so the whole control is sized around that.
Tight vertical padding keeps it ~36px tall, icon : control ≈ 1 : φ. */
.input-box {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 4px 5px 4px 11px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
transition: border-color 0.2s ease;
}
.input-box:focus-within { border-color: rgba(255, 77, 77, 0.55); }
.input-icon {
width: 23px;
height: 23px;
flex: 0 0 auto;
color: var(--muted);
}
.input,
input[type="text"] {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text);
font-size: 16px;
padding: 5px 2px;
outline: none;
}
.input::placeholder { color: rgba(154, 166, 192, 0.7); }
.btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
border: none;
background: var(--grad);
color: #fff;
padding: 6px 15px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 6px 18px rgba(255, 45, 114, 0.35);
transition: transform 0.1s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.btn svg { width: 17px; height: 17px; }
.btn:hover { box-shadow: 0 8px 24px rgba(255, 45, 114, 0.5); }
.btn:active { transform: scale(0.97); }
/* ----- Toast (the reply from Go) ------------------------------------------- */
/* Fixed overlay near the bottom, so showing the greeting never reflows the page.
The Go-blue badge makes it obvious the string was produced by the Go backend. */
.toast {
position: fixed;
/* Top-right with an equal golden-ratio inset (φ = 1.618rem) on both edges,
clear of the macOS traffic lights (which are on the left). Falls back to
the notch / safe area when larger. Drops in from just above. */
top: max(var(--s-3), env(safe-area-inset-top));
right: max(var(--s-3), env(safe-area-inset-right));
z-index: 20;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
max-width: min(86vw, 420px);
padding: 11px 16px;
background: rgba(13, 17, 28, 0.72);
border: 1px solid var(--glass-border);
border-radius: 14px;
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.45);
color: var(--text);
font-size: 15px;
line-height: 1.45;
opacity: 0;
pointer-events: none;
transform: translateY(-16px);
transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Muted, on-palette label so it's clear the string came from the Go backend. */
.toast-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
.toast-msg { min-width: 0; }
/* ----- Footer -------------------------------------------------------------- */
/* Thin bar pinned to the bottom: version far-left, live clock centered. */
.footer-divider {
width: 100%;
height: 0;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 0;
}
.footer {
display: grid;
grid-template-columns: 1fr auto 1fr; /* version | clock | spacer */
align-items: center;
width: 100%;
/* keep the bar off the very edge and clear of the home indicator */
padding: 11px max(0.5rem, env(safe-area-inset-right))
max(11px, env(safe-area-inset-bottom)) 0;
color: var(--muted);
font-size: 12.5px;
}
.footer-version {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
}
.footer-time {
justify-self: center;
display: inline-flex;
align-items: center;
gap: 7px;
}
.footer-time svg { width: 19px; height: 19px; opacity: 0.85; }
.footer-docs {
justify-self: end;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--muted);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
transition: color 0.2s ease;
}
.footer-docs:hover { color: var(--text); }
.footer-docs svg { width: 17px; height: 17px; }
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
/* ----- Mobile -------------------------------------------------------------- */
@media (max-width: 640px) {
.bg {
background:
linear-gradient(0deg, rgba(6, 7, 15, 0.92) 0%, rgba(6, 7, 15, 0.55) 40%,
rgba(6, 7, 15, 0.45) 100%),
url("/bg-mobile.jpg") center / cover no-repeat;
}
.container { align-items: center; text-align: center; margin: 0 auto; }
/* Stack the title onto two lines ("Wails +" / "Javascript") like the target. */
.title-accent { display: block; }
.subtitle { margin-left: auto; margin-right: auto; }
/* A touch more width back on small screens so the input stays usable. */
.greet { width: 86%; }
/* Trim the surround on a phone, but keep the input at 16px (iOS focus-zoom
guard) — only the padding/button/icon shrink, not the field's text. */
.input-box { padding: 3px 4px 3px 10px; gap: 6px; }
.input-icon { width: 21px; height: 21px; }
.input { font-size: 16px; padding: 4px 2px; }
.btn { padding: 5px 13px; font-size: 14px; gap: 5px; }
.btn svg { width: 16px; height: 16px; }
/* Three items (version / clock / docs) share a narrow row — shrink to fit. */
.footer { font-size: 11px; gap: 4px; }
.footer-time { gap: 5px; }
.footer-time svg { width: 13px; height: 13px; }
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+328
View File
@@ -0,0 +1,328 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { Anchor, ShieldAlert, HeartHandshake, Menu, X } from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const mobileMenuOpen = ref(false)
const navItems = [
{ name: '메인 홈', path: '/', icon: Anchor },
{ name: '캠페인 소개', path: '/about', icon: ShieldAlert },
{ name: '희망 기부', path: '/donation', icon: HeartHandshake }
]
const navigateTo = (path: string) => {
router.push(path)
mobileMenuOpen.value = false
}
</script>
<template>
<div class="app-layout">
<!-- Header -->
<header class="app-header">
<div class="header-logo" @click="navigateTo('/')">
<div class="logo-icon-wrapper">
<Anchor class="logo-icon" />
</div>
<div class="logo-text">
<span class="logo-title">희망의 바다</span>
<span class="logo-subtitle">수협중앙회</span>
</div>
</div>
<!-- Desktop Nav -->
<nav class="desktop-nav">
<button
v-for="item in navItems"
:key="item.path"
:class="['nav-link', { active: route.path === item.path }]"
@click="navigateTo(item.path)"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.name }}</span>
</button>
</nav>
<!-- Mobile Nav Toggle -->
<button class="mobile-nav-toggle" @click="mobileMenuOpen = !mobileMenuOpen">
<Menu v-if="!mobileMenuOpen" />
<X v-else />
</button>
</header>
<!-- Mobile Nav Menu -->
<transition name="slide-fade">
<nav v-if="mobileMenuOpen" class="mobile-nav">
<button
v-for="item in navItems"
:key="item.path"
:class="['mobile-nav-link', { active: route.path === item.path }]"
@click="navigateTo(item.path)"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.name }}</span>
</button>
</nav>
</transition>
<!-- Main Content Area -->
<main class="main-content">
<RouterView v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</RouterView>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="footer-content">
<p class="footer-title">수협중앙회 희망의 바다 만들기 운동본부</p>
<p class="footer-text">© 2026 수협중앙회. All rights reserved. 깨끗하고 풍요로운 바다, 함께 만들어갑니다.</p>
</div>
</footer>
</div>
</template>
<style>
/* 전역 스코프 및 공통 레이아웃 스타일 정의 */
:root {
--primary-color: #00d2ff;
--primary-rgb: 0, 210, 255;
--secondary-color: #0066ff;
--accent-color: #ff6b6b;
--dark-bg: #030814;
--glass-bg: rgba(6, 17, 38, 0.65);
--glass-border: rgba(0, 210, 255, 0.15);
--text-color: #e0f2fe;
}
body {
background: radial-gradient(circle at center, #061530 0%, #030814 100%) !important;
color: var(--text-color);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0 !important; /* Wails 기본 padding 덮어쓰기 */
}
/* Wails default background override */
.bg {
display: none !important;
}
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 70px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 40px;
z-index: 100;
--wails-draggable: drag;
}
.header-logo {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
--wails-draggable: no-drag;
}
.logo-icon-wrapper {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
padding: 8px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 15px rgba(0, 210, 255, 0.4);
}
.logo-icon {
color: white;
width: 20px;
height: 20px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.logo-text {
display: flex;
flex-direction: column;
}
.logo-title {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.5px;
background: linear-gradient(to right, #ffffff, var(--primary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.logo-subtitle {
font-size: 0.75rem;
color: #94a3b8;
}
.desktop-nav {
display: flex;
gap: 8px;
--wails-draggable: no-drag;
}
.nav-link {
background: transparent;
border: none;
color: #94a3b8;
padding: 8px 16px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
}
.nav-link:hover {
color: var(--primary-color);
background: rgba(0, 210, 255, 0.08);
}
.nav-link.active {
color: white;
background: linear-gradient(135deg, rgba(0, 210, 255, 0.2), rgba(0, 102, 255, 0.2));
border: 1px solid var(--primary-color);
box-shadow: 0 0 15px rgba(0, 210, 255, 0.25);
}
.nav-icon {
width: 16px;
height: 16px;
}
.mobile-nav-toggle {
display: none;
background: transparent;
border: none;
color: white;
cursor: pointer;
--wails-draggable: no-drag;
}
.main-content {
flex: 1;
margin-top: 70px;
padding: 40px;
display: flex;
flex-direction: column;
}
.app-footer {
background: #020611;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 30px 40px;
text-align: center;
font-size: 0.85rem;
color: #64748b;
}
.footer-title {
font-weight: 700;
color: #94a3b8;
margin-bottom: 8px;
}
.footer-text {
margin: 0;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-20px);
opacity: 0;
}
/* Responsiveness */
@media (max-width: 768px) {
.desktop-nav {
display: none;
}
.mobile-nav-toggle {
display: block;
}
.main-content {
padding: 20px;
}
.mobile-nav {
position: fixed;
top: 70px;
left: 0;
right: 0;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
padding: 15px;
gap: 8px;
z-index: 99;
}
.mobile-nav-link {
background: transparent;
border: none;
color: #94a3b8;
padding: 12px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.mobile-nav-link.active {
color: white;
background: rgba(0, 210, 255, 0.15);
}
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import {ref, onMounted, useTemplateRef} from 'vue'
import {Events, WML} from "@wailsio/runtime";
import {GreetService} from "../../bindings/changeme";
// Show the actual Wails version this project was generated against.
const wailsVersion = "v3.0.0-alpha2.106";
const name = ref('');
const time = ref('Listening for Time event...');
const titleNameRef = useTemplateRef<HTMLElement>('titleNameRef');
const toastRef = useTemplateRef<HTMLElement>('toastRef');
const resultRef = useTemplateRef<HTMLElement>('resultRef');
let toastTimer: ReturnType<typeof setTimeout>;
// Crossfade the framework word in the heading ("Wails + Vue") to the name the
// user entered ("Wails + <name>"): the old word fades out while the new one
// fades in over the same spot.
function swapTitleName(newName: string) {
const titleNameElement = titleNameRef.value;
if (!titleNameElement) {
return;
}
const current = titleNameElement.querySelector('.title-name-text:not(.is-outgoing)');
if (!current || current.textContent === newName) {
return;
}
const incoming = document.createElement('span');
incoming.className = 'title-name-text is-entering';
incoming.textContent = newName;
current.classList.add('is-outgoing');
titleNameElement.appendChild(incoming);
// Force a reflow so the transitions run from the starting state.
void incoming.offsetWidth;
incoming.classList.remove('is-entering');
current.classList.add('is-leaving');
current.addEventListener('transitionend', () => current.remove(), {once: true});
}
// Pop the toast with the message Go returned, then auto-dismiss it.
function showToast(message: string) {
if (!resultRef.value || !toastRef.value) {
return;
}
resultRef.value.innerText = message;
toastRef.value.classList.add('is-visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastRef.value?.classList.remove('is-visible'), 4000);
}
const doGreet = () => {
let n = name.value || 'anonymous';
swapTitleName(n);
GreetService.Greet(n).then(showToast).catch(console.error);
}
onMounted(() => {
Events.On('time', (timeValue: { data: string }) => {
// On a narrow screen the full RFC1123 stamp is too wide for the footer, so
// show just the clock time there (matching the CSS breakpoint).
const full = timeValue.data;
const compact = (full.match(/\d{1,2}:\d{2}:\d{2}/) || [full])[0];
time.value = window.matchMedia('(max-width: 640px)').matches ? compact : full;
});
// Wire up data-wml-openURL links (logos + footer "Docs" link).
WML.Reload();
})
</script>
<template>
<main class="container">
<header class="brand">
<a class="brand-mark" data-wml-openURL="https://v3.wails.io" aria-label="Wails website">
<img src="/wails.png" class="brand-logo" alt="Wails logo"/>
</a>
<a class="brand-badge" data-wml-openURL="https://vuejs.org/" aria-label="Vue">
<img src="/vue.svg" alt="Vue logo"/>
</a>
</header>
<h1 class="title"><span class="title-accent">Wails +</span> <span class="title-name" ref="titleNameRef"><span class="title-name-text">Vue</span></span></h1>
<p class="subtitle">Build beautiful cross-platform apps with Go and Vue.</p>
<div class="greet">
<div class="input-box" id="input">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input aria-label="input" class="input" id="name" v-model="name" type="text" placeholder="Your name" autocomplete="off"/>
<button aria-label="greet-btn" class="btn" @click="doGreet">Greet
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</button>
</div>
</div>
</main>
<hr class="footer-divider"/>
<footer class="footer">
<span class="footer-version">{{ wailsVersion }}</span>
<span class="footer-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="time">{{ time }}</span>
</span>
<a class="footer-docs" data-wml-openURL="https://v3.wails.io" aria-label="Wails documentation">Docs
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
</a>
</footer>
<!-- Toast: shows the greeting returned by the Go backend (overlay, so it never
reflows the layout). -->
<div class="toast" id="toast" ref="toastRef" role="status" aria-live="polite">
<span class="toast-label">From Go</span>
<span aria-label="result" class="toast-msg" id="result" ref="resultRef"></span>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
+29
View File
@@ -0,0 +1,29 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';
import About from '../views/About.vue';
import Donation from '../views/Donation.vue';
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
},
{
path: '/donation',
name: 'Donation',
component: Donation
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;
+563
View File
@@ -0,0 +1,563 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowRight, Compass, Shield, Award, Users } from 'lucide-vue-next'
const router = useRouter()
const activeTab = ref('seed')
const activities = {
seed: {
title: '수산종자 방류 사업',
subtitle: '수산자원의 회복과 복원',
desc: '고갈되어 가는 우리 바다의 어족 자원을 늘리고 어업인의 소득을 보장하기 위해, 지역 해역 특성에 맞는 우수한 품종의 치어를 방류합니다.',
points: [
'방류 효과 분석을 바탕으로 한 경제성 높은 어종 선정',
'전국 연안 지자체 및 회원조합과 공동으로 매년 추진',
'감성돔, 조피볼락, 꽃게 등 대표 고부가가치 품종 방류',
'어업인의 수산 소득 증대 및 미래 생태 식량 확보'
],
infographic: [
{ step: '01', title: '우량 종자 확보', desc: '질병 검사를 마친 건강한 수산 치어 선별' },
{ step: '02', title: '해역 적합도 평가', desc: '지역별 염도 및 온도에 알맞은 어종 연구' },
{ step: '03', title: '체계적 방류', desc: '수산 자원 고갈 지점에서 집중 방류 실시' },
{ step: '04', title: '추적 및 관리', desc: '방류된 치어의 생존율 및 자원조성 효과 모니터링' }
]
},
cleanup: {
title: '해양 쓰레기 수거 사업',
subtitle: '조업 중 쓰레기 수매 및 수거 지원',
desc: '바다 밑에 침적된 폐어구, 플라스틱 등 어업 활동을 방해하고 해양 생태계를 오염시키는 해양 쓰레기를 신속히 수거하여 깨끗한 환경을 만듭니다.',
points: [
'어업인 참여 유도를 위한 조업 중 인양 쓰레기 수매 사업 진행',
'시민단체 및 연안 해안가 집중 정화 운동 전개',
'침적 폐그물 수거를 통해 해양 동물의 유령어업(Ghost Fishing) 방지',
'해양 플라스틱 오염 방지를 통한 미세 플라스틱 먹이사슬 차단'
],
infographic: [
{ step: '01', title: '인양 및 수집', desc: '조업 중 수거한 쓰레기를 항구로 반입' },
{ step: '02', title: '수협 수매 시스템', desc: '수협이 매입하여 쓰레기 회수율 극대화' },
{ step: '03', title: '친환경 처리', desc: '재활용 가능 플라스틱과 폐어구 분류 처리' },
{ step: '04', title: '바다 청정화', desc: '어장 환경의 개선과 선박 안전 운항 확보' }
]
},
restoration: {
title: '바닷속 환경 정화 사업',
subtitle: '갯녹음 치유 및 생태계 복원',
desc: '지구 온난화와 환경 오염으로 바다가 하얗게 사막화되는 갯녹음 현상(백화현상)을 방지하고, 해조류가 자라는 풍요로운 바다 숲을 만듭니다.',
points: [
'갯녹음 해역의 해적생물(성게 등) 집중 구제 및 유해 유기물 제거',
'어장 환경 개선을 위한 바닥갈이(경운) 및 물갈이 사업 지원',
'미생물 및 친환경 공법을 활용한 바다 해저 토양 정화',
'바다숲 조성을 통해 수산 생물의 산란장 및 서식지 제공'
],
infographic: [
{ step: '01', title: '현황 정밀 조사', desc: '갯녹음 및 바다 바닥 황폐화 상태 과학적 진단' },
{ step: '02', title: '어장 바닥갈이', desc: '경운 작업을 통해 암반의 퇴적물과 유해물질 세척' },
{ step: '03', title: '해조류 이식 및 관리', desc: '다시마, 감태 등 해조류를 안착시켜 바다숲 형성' },
{ step: '04', title: '풍요로운 어장 복원', desc: '어패류가 산란하고 성장할 수 있는 보금자리 완성' }
]
}
}
</script>
<template>
<div class="about-container">
<!-- Intro Hero -->
<section class="about-hero">
<div class="glow-bg"></div>
<div class="hero-content">
<h1 class="about-title">바다를 살리는 3가지 약속</h1>
<p class="about-subtitle-text">
수협중앙회는 2007년부터 전국의 어업인들과 힘을 합쳐,<br />
바다의 수산 자원을 회복하고 해양 환경을 맑게 가꾸는 공익적 실천을 이어가고 있습니다.
</p>
</div>
</section>
<!-- Tabbed Activities Grid -->
<section class="activities-section">
<div class="tab-buttons">
<button
:class="['tab-btn', { active: activeTab === 'seed' }]"
@click="activeTab = 'seed'"
>
<Compass class="tab-icon" />
<span>수산종자 방류</span>
</button>
<button
:class="['tab-btn', { active: activeTab === 'cleanup' }]"
@click="activeTab = 'cleanup'"
>
<Shield class="tab-icon" />
<span>쓰레기 수거</span>
</button>
<button
:class="['tab-btn', { active: activeTab === 'restoration' }]"
@click="activeTab = 'restoration'"
>
<Award class="tab-icon" />
<span>바닷속 환경 정화</span>
</button>
</div>
<!-- Tab Content Area -->
<transition name="fade" mode="out-in">
<div :key="activeTab" class="tab-content">
<div class="content-left">
<h2 class="content-title">{{ activities[activeTab].title }}</h2>
<h3 class="content-subtitle">{{ activities[activeTab].subtitle }}</h3>
<p class="content-desc">{{ activities[activeTab].desc }}</p>
<ul class="point-list">
<li v-for="(point, idx) in activities[activeTab].points" :key="idx" class="point-item">
<span class="bullet"></span>
<span>{{ point }}</span>
</li>
</ul>
</div>
<div class="content-right">
<h4 class="right-title">추진 프로세스</h4>
<div class="infographic-flow">
<div
v-for="step in activities[activeTab].infographic"
:key="step.step"
class="flow-step"
>
<div class="step-num">{{ step.step }}</div>
<div class="step-info">
<h5 class="step-title">{{ step.title }}</h5>
<p class="step-desc">{{ step.desc }}</p>
</div>
</div>
</div>
</div>
</div>
</transition>
</section>
<!-- Citizen Campaign Info -->
<section class="citizen-section">
<div class="citizen-card">
<div class="citizen-icon-wrapper">
<Users class="citizen-icon" />
</div>
<div class="citizen-info">
<span class="citizen-badge">국민 참여 프로그램</span>
<h2 class="citizen-title">모두의 바다, 함께海 캠페인</h2>
<p class="citizen-desc">
해양 환경 정화에 관심이 있는 학생, 일반 시민 모임, 동호회가 바닷가 정화 활동을 실천할 있도록 수협중앙회가 활동 경비(최대 100 ) 지원합니다.
</p>
<div class="citizen-benefits">
<div class="benefit-item">
<div class="benefit-dot"></div>
<span>전국 ·포구 해안가 정화 단체 지원</span>
</div>
<div class="benefit-item">
<div class="benefit-dot"></div>
<span>청소년 교육 환경 인식 제고</span>
</div>
<div class="benefit-item">
<div class="benefit-dot"></div>
<span>기금 기부를 통한 가상의 바다 정화 동참</span>
</div>
</div>
<button class="btn btn-primary" @click="router.push('/donation')">
<span>참여하고 바다 가꾸기</span>
<ArrowRight class="btn-icon" />
</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.about-container {
display: flex;
flex-direction: column;
gap: 80px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
/* About Hero */
.about-hero {
position: relative;
text-align: center;
padding: 60px 20px;
border-radius: 24px;
background: rgba(6, 17, 38, 0.4);
border: 1px solid rgba(0, 210, 255, 0.08);
overflow: hidden;
}
.glow-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(0, 210, 255, 0.15) 0%, transparent 70%);
z-index: 1;
pointer-events: none;
}
.hero-content {
position: relative;
z-index: 2;
}
.about-title {
font-size: 2.5rem;
font-weight: 800;
margin: 0 0 16px 0;
background: linear-gradient(to right, #ffffff, var(--primary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.about-subtitle-text {
font-size: 1.1rem;
color: #94a3b8;
line-height: 1.6;
max-width: 750px;
margin: 0 auto;
}
/* Activities Section */
.activities-section {
display: flex;
flex-direction: column;
gap: 40px;
}
.tab-buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.tab-btn {
background: rgba(6, 17, 38, 0.6);
border: 1px solid rgba(0, 210, 255, 0.1);
padding: 16px 30px;
border-radius: 16px;
color: #94a3b8;
font-size: 1.05rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.tab-btn:hover {
border-color: rgba(0, 210, 255, 0.3);
color: var(--primary-color);
transform: translateY(-2px);
}
.tab-btn.active {
background: linear-gradient(135deg, rgba(0, 210, 255, 0.15), rgba(0, 102, 255, 0.15));
border-color: var(--primary-color);
color: white;
box-shadow: 0 5px 20px rgba(0, 210, 255, 0.15);
}
.tab-icon {
width: 20px;
height: 20px;
}
.tab-content {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 40px;
background: rgba(6, 17, 38, 0.4);
border: 1px solid rgba(0, 210, 255, 0.08);
border-radius: 24px;
padding: 40px;
backdrop-filter: blur(10px);
}
.content-left {
display: flex;
flex-direction: column;
justify-content: center;
}
.content-title {
font-size: 1.8rem;
font-weight: 800;
color: white;
margin: 0 0 8px 0;
}
.content-subtitle {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
margin: 0 0 20px 0;
}
.content-desc {
font-size: 1rem;
line-height: 1.6;
color: #94a3b8;
margin-bottom: 25px;
}
.point-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.point-item {
display: flex;
align-items: flex-start;
gap: 10px;
color: #cbd5e1;
font-size: 0.95rem;
line-height: 1.4;
}
.bullet {
width: 6px;
height: 6px;
background: var(--primary-color);
border-radius: 50%;
margin-top: 8px;
flex-shrink: 0;
box-shadow: 0 0 8px var(--primary-color);
}
/* Right Column Infographic Flow */
.content-right {
border-left: 1px solid rgba(255, 255, 255, 0.05);
padding-left: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
.right-title {
font-size: 1.1rem;
font-weight: 700;
color: #94a3b8;
margin: 0 0 20px 0;
letter-spacing: 0.5px;
}
.infographic-flow {
display: flex;
flex-direction: column;
gap: 20px;
}
.flow-step {
display: flex;
gap: 15px;
align-items: flex-start;
position: relative;
}
.flow-step:not(:last-child)::after {
content: '';
position: absolute;
top: 30px;
left: 17px;
width: 2px;
height: calc(100% - 10px);
background: linear-gradient(to bottom, var(--primary-color), rgba(0, 102, 255, 0.1));
}
.step-num {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
font-size: 0.95rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 10px rgba(0, 210, 255, 0.3);
}
.step-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.step-title {
font-size: 0.95rem;
font-weight: 700;
color: white;
margin: 0;
}
.step-desc {
font-size: 0.85rem;
color: #64748b;
margin: 0;
line-height: 1.4;
}
/* Citizen Campaign Info */
.citizen-section {
width: 100%;
}
.citizen-card {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 40px;
background: radial-gradient(circle at bottom right, rgba(0, 102, 255, 0.1) 0%, rgba(3, 8, 20, 0.8) 80%);
border: 1px solid rgba(0, 210, 255, 0.15);
border-radius: 24px;
padding: 50px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.citizen-icon-wrapper {
background: linear-gradient(135deg, rgba(0, 210, 255, 0.1), rgba(0, 102, 255, 0.1));
border: 1px solid rgba(0, 210, 255, 0.2);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.citizen-icon {
width: 80px;
height: 80px;
color: var(--primary-color);
animation: pulse 2s infinite alternate;
}
@keyframes pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(0, 210, 255, 0.2)); }
100% { transform: scale(1.05); filter: drop-shadow(0 0 20px rgba(0, 210, 255, 0.5)); }
}
.citizen-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.citizen-badge {
font-size: 0.8rem;
font-weight: 700;
color: #ff6b6b;
background: rgba(255, 107, 107, 0.12);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.citizen-title {
font-size: 1.8rem;
font-weight: 800;
color: white;
margin: 0;
}
.citizen-desc {
font-size: 1rem;
line-height: 1.6;
color: #94a3b8;
margin: 0;
}
.citizen-benefits {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.benefit-item {
display: flex;
align-items: center;
gap: 8px;
color: #cbd5e1;
font-size: 0.95rem;
}
.benefit-dot {
width: 6px;
height: 6px;
background: #ff6b6b;
border-radius: 50%;
}
.btn-icon {
width: 16px;
height: 16px;
}
/* Animations */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* Responsive */
@media (max-width: 992px) {
.tab-content {
grid-template-columns: 1fr;
gap: 30px;
}
.content-right {
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-left: 0;
padding-top: 30px;
}
.citizen-card {
grid-template-columns: 1fr;
gap: 30px;
padding: 30px;
}
.citizen-icon-wrapper {
padding: 20px;
}
.citizen-icon {
width: 60px;
height: 60px;
}
}
@media (max-width: 768px) {
.tab-buttons {
flex-direction: column;
gap: 10px;
}
.tab-btn {
width: 100%;
justify-content: center;
}
}
</style>
+784
View File
@@ -0,0 +1,784 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Heart, Send, CheckCircle2, MessageSquare, Shield, Fish, Trash2, HelpCircle } from 'lucide-vue-next'
const stats = ref({
totalDonors: 12543,
totalAmount: 627150000,
releasedFish: 4200000,
collectedWaste: 185000,
purifiedArea: 95000
})
const recentDonations = ref<any[]>([])
const form = ref({
name: '',
amount: 30000,
message: ''
})
const showSuccess = ref(false)
const isSubmitting = ref(false)
const predefinedAmounts = [10000, 30000, 50000, 100000, 300000]
// Real-time impact simulation computed values
const simulatedImpact = computed(() => {
const amount = form.value.amount || 0
const multiplier = amount / 10000
return {
fish: Math.floor(multiplier * 2),
waste: (multiplier * 0.5).toFixed(1),
area: (multiplier * 0.2).toFixed(2)
}
})
const loadData = async () => {
try {
// @ts-ignore
const CampaignService = await import('../../bindings/changeme/campaignservice')
const currentStats = await CampaignService.GetStats()
if (currentStats) stats.value = currentStats
const list = await CampaignService.GetRecentDonations()
if (list) {
// Reverse list to show newest first
recentDonations.value = [...list].reverse()
}
} catch (e) {
console.warn('Wails bindings not available, using mock local data.', e)
// Local mock data fallback
recentDonations.value = [
{ name: '김어부', amount: 50000, message: '바다가 다시 풍요로워지길 바랍니다.', timestamp: new Date(Date.now() - 10 * 60000).toISOString() },
{ name: '이바다', amount: 30000, message: '깨끗한 바다를 우리 아이들에게!', timestamp: new Date(Date.now() - 60 * 60000).toISOString() },
{ name: '박수협', amount: 100000, message: '어업인 여러분 힘내세요!', timestamp: new Date(Date.now() - 180 * 60000).toISOString() }
]
}
}
onMounted(() => {
loadData()
})
const handleDonate = async () => {
if (form.value.amount <= 0) return
isSubmitting.value = true
try {
// @ts-ignore
const CampaignService = await import('../../bindings/changeme/campaignservice')
const newStats = await CampaignService.Donate(form.value.name, form.value.amount, form.value.message)
if (newStats) stats.value = newStats
// Refresh donation list
const list = await CampaignService.GetRecentDonations()
if (list) recentDonations.value = [...list].reverse()
showSuccessModal()
} catch (e) {
console.warn('Wails backend Donate call skipped. Simulating successfully on frontend.', e)
// Simulating locally if not running in Wails
stats.value.totalDonors += 1
stats.value.totalAmount += form.value.amount
const multiplier = form.value.amount / 10000
stats.value.releasedFish += Math.floor(multiplier * 2)
stats.value.collectedWaste += Math.floor(multiplier * 0.5)
stats.value.purifiedArea += Math.floor(multiplier * 0.2)
recentDonations.value.unshift({
name: form.value.name || '익명의 기부자',
amount: form.value.amount,
message: form.value.message || '푸른 바다를 함께 응원합니다!',
timestamp: new Date().toISOString()
})
showSuccessModal()
} finally {
isSubmitting.value = false
}
}
const showSuccessModal = () => {
showSuccess.value = true
// Reset form
form.value.name = ''
form.value.message = ''
}
const formatNumber = (num: number) => {
return new Intl.NumberFormat('ko-KR').format(num)
}
const formatDate = (dateStr: string) => {
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>
<template>
<div class="donation-container">
<!-- Hero Header -->
<div class="donation-hero">
<h1 class="page-title">희망의 바다 기금 기부</h1>
<p class="page-subtitle">수협중앙회와 함께 맑은 바다 생태계를 가꾸는 가장 가치 있는 첫걸음입니다.</p>
</div>
<!-- Main Grid layout -->
<div class="donation-grid">
<!-- Left Column: Form and Impact simulation -->
<div class="column-left">
<!-- Interactive Impact Simulator Card -->
<div class="card simulator-card">
<h3 class="card-title"><Fish class="card-icon" /> 실시간 기부 효과 시뮬레이션</h3>
<p class="card-desc">기부하시는 금액에 따라 우리 바다에 생겨날 놀라운 변화를 확인해보세요.</p>
<div class="simulator-graphic">
<!-- Ocean environment visual feedback -->
<div class="mini-ocean">
<div class="sim-bubbles">
<div v-for="n in 6" :key="n" class="sim-bubble" :style="`--i:${n}`"></div>
</div>
<!-- Floating simulated fish -->
<div class="fish-pond">
<div
v-for="n in Math.min(simulatedImpact.fish, 10)"
:key="n"
class="sim-fish"
:style="`--speed:${1.5 + Math.random() * 2}s; --delay:${Math.random() * 2}s; --y:${15 + (n * 8)}%`"
>
🐟
</div>
<div v-if="simulatedImpact.fish === 0" class="no-fish-text">치어가 방류 대기 중입니다.</div>
</div>
</div>
</div>
<!-- Impact Metrics Grid -->
<div class="impact-metrics">
<div class="metric-box">
<span class="m-icon"><Fish /></span>
<span class="m-val">{{ simulatedImpact.fish }} 마리</span>
<span class="m-lbl">수산 치어 방류</span>
</div>
<div class="metric-box">
<span class="m-icon"><Trash2 /></span>
<span class="m-val">{{ simulatedImpact.waste }} kg</span>
<span class="m-lbl">해양 쓰레기 수거</span>
</div>
<div class="metric-box">
<span class="m-icon"><Shield /></span>
<span class="m-val">{{ simulatedImpact.area }} </span>
<span class="m-lbl">바닷속 갯녹음 정화</span>
</div>
</div>
</div>
<!-- Donation Form Card -->
<div class="card form-card">
<h3 class="card-title"><Heart class="card-icon form-icon" /> 가상 후원 참여 </h3>
<p class="card-desc">후원 금액과 응원 메시지는 실시간 캠페인 대시보드에 반영됩니다.</p>
<form @submit.prevent="handleDonate" class="donation-form">
<!-- Name Input -->
<div class="form-group">
<label for="donor-name" class="form-label">후원자명 (또는 단체명)</label>
<input
id="donor-name"
type="text"
v-model="form.name"
placeholder="익명의 기부자"
class="form-input"
/>
</div>
<!-- Amount Input & Preset Buttons -->
<div class="form-group">
<label class="form-label">후원 금액 선택</label>
<div class="preset-buttons">
<button
type="button"
v-for="amt in predefinedAmounts"
:key="amt"
:class="['preset-btn', { active: form.amount === amt }]"
@click="form.amount = amt"
>
{{ formatNumber(amt / 10000) }}
</button>
</div>
<div class="custom-amount-input">
<input
type="number"
v-model.number="form.amount"
placeholder="직접 입력 (원)"
min="1000"
step="1000"
class="form-input custom-input"
/>
<span class="currency-label"></span>
</div>
</div>
<!-- Message Input -->
<div class="form-group">
<label for="donor-msg" class="form-label">바다를 위한 응원 메시지</label>
<textarea
id="donor-msg"
v-model="form.message"
placeholder="깨끗한 바다를 향한 어업인과 수협에 전할 격려의 한마디를 작성해주세요."
class="form-textarea"
rows="3"
></textarea>
</div>
<button type="submit" class="btn btn-submit" :disabled="isSubmitting || form.amount < 1000">
<Heart class="submit-icon" />
<span>{{ isSubmitting ? '전송 중...' : '기금 후원하기' }}</span>
</button>
</form>
</div>
</div>
<!-- Right Column: Live dashboard stats and recent list -->
<div class="column-right">
<!-- Campaign Stats Box -->
<div class="card stats-box-card">
<h3 class="card-title">실시간 희망 바다 현황</h3>
<div class="simple-stat-row">
<span class="simple-lbl">누적 기금 조성액</span>
<span class="simple-val accent-text">{{ formatNumber(stats.totalAmount) }} </span>
</div>
<div class="simple-stat-row">
<span class="simple-lbl">누적 동참자</span>
<span class="simple-val">{{ formatNumber(stats.totalDonors) }} </span>
</div>
</div>
<!-- Recent Donations Feed -->
<div class="card feed-card">
<h3 class="card-title"><MessageSquare class="card-icon" /> 실시간 참여 피드</h3>
<div class="donation-feed">
<transition-group name="list">
<div v-for="(item, idx) in recentDonations" :key="idx" class="feed-item">
<div class="feed-header">
<span class="feed-name">{{ item.name }} </span>
<span class="feed-amt">{{ formatNumber(item.amount) }} 후원</span>
</div>
<p class="feed-msg">"{{ item.message || '풍요로운 바다의 미래를 힘차게 응원합니다!' }}"</p>
<span class="feed-time">{{ formatDate(item.timestamp) }}</span>
</div>
</transition-group>
<div v-if="recentDonations.length === 0" class="empty-feed">
번째 기부 참여를 기다리고 있습니다.
</div>
</div>
</div>
</div>
</div>
<!-- Success Modal popup -->
<div v-if="showSuccess" class="modal-overlay" @click.self="showSuccess = false">
<div class="modal-content">
<CheckCircle2 class="success-big-icon" />
<h2 class="modal-title">후원이 완료되었습니다!</h2>
<p class="modal-desc">
보내주신 따뜻한 희망의 씨앗이 건강한 바다 생태계 복원과 우리 어업인 지원 기금에 소중하게 사용됩니다. 감사합니다!
</p>
<button class="btn btn-primary btn-modal-close" @click="showSuccess = false">확인</button>
</div>
</div>
</div>
</template>
<style scoped>
.donation-container {
display: flex;
flex-direction: column;
gap: 40px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.donation-hero {
text-align: center;
}
.page-title {
font-size: 2.2rem;
font-weight: 800;
margin: 0 0 10px 0;
background: linear-gradient(to right, #ffffff, var(--primary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-subtitle {
color: #64748b;
margin: 0;
}
.donation-grid {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 30px;
}
.column-left, .column-right {
display: flex;
flex-direction: column;
gap: 30px;
}
/* Card Common Base */
.card {
background: rgba(6, 17, 38, 0.4);
border: 1px solid rgba(0, 210, 255, 0.08);
border-radius: 20px;
padding: 30px;
backdrop-filter: blur(10px);
}
.card-title {
font-size: 1.25rem;
font-weight: 800;
color: white;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 8px;
}
.card-icon {
width: 20px;
height: 20px;
color: var(--primary-color);
}
.card-desc {
font-size: 0.9rem;
color: #64748b;
margin: 0 0 20px 0;
}
/* Live Simulator Visual Graphic */
.simulator-card {
border-color: rgba(0, 210, 255, 0.15);
}
.simulator-graphic {
width: 100%;
margin-bottom: 24px;
}
.mini-ocean {
width: 100%;
height: 180px;
background: linear-gradient(to bottom, #072a56 0%, #03152d 100%);
border-radius: 12px;
border: 1px solid rgba(0, 210, 255, 0.2);
position: relative;
overflow: hidden;
}
.sim-bubbles {
position: absolute;
inset: 0;
}
.sim-bubble {
position: absolute;
bottom: -10px;
background: rgba(0, 210, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 8px;
height: 8px;
left: calc(var(--i) * 15%);
animation: sim-bubble-up 3s linear infinite;
animation-delay: calc(var(--i) * 0.4s);
}
@keyframes sim-bubble-up {
0% { transform: translateY(0) scale(1); opacity: 0; }
20% { opacity: 0.6; }
90% { opacity: 0.6; }
100% { transform: translateY(-200px) scale(1.3); opacity: 0; }
}
.fish-pond {
position: absolute;
inset: 0;
}
.sim-fish {
position: absolute;
font-size: 1.3rem;
animation: swim var(--speed) linear infinite alternate;
animation-delay: var(--delay);
left: -20px;
top: var(--y);
}
@keyframes swim {
0% { left: -20px; transform: scaleX(1); }
45% { transform: scaleX(1); }
50% { transform: scaleX(-1); }
95% { transform: scaleX(-1); }
100% { left: calc(100% - 10px); transform: scaleX(1); }
}
.no-fish-text {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
font-weight: 600;
font-size: 0.9rem;
}
/* Impact Metrics */
.impact-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.metric-box {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(0, 210, 255, 0.08);
border-radius: 12px;
padding: 15px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.m-icon {
color: var(--primary-color);
width: 20px;
height: 20px;
display: flex;
align-items: center;
}
.m-val {
font-size: 1.1rem;
font-weight: 800;
color: white;
}
.m-lbl {
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
}
/* Donation Form */
.donation-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 0.9rem;
font-weight: 700;
color: #cbd5e1;
}
.form-input, .form-textarea {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px 16px;
color: white;
font-family: inherit;
font-size: 0.95rem;
transition: all 0.3s ease;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 10px rgba(0, 210, 255, 0.2);
}
.preset-buttons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 8px;
}
.preset-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 10px;
color: #94a3b8;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: rgba(0, 210, 255, 0.08);
color: var(--primary-color);
}
.preset-btn.active {
background: rgba(0, 210, 255, 0.15);
border-color: var(--primary-color);
color: white;
}
.custom-amount-input {
display: flex;
align-items: center;
position: relative;
margin-top: 5px;
}
.custom-input {
width: 100%;
padding-right: 40px;
}
.currency-label {
position: absolute;
right: 15px;
color: #64748b;
font-weight: 700;
}
.btn-submit {
background: linear-gradient(135deg, #ff6b6b, #ff4747);
color: white;
justify-content: center;
padding: 15px;
border-radius: 12px;
font-size: 1.05rem;
font-weight: 700;
box-shadow: 0 5px 15px rgba(255, 107, 107, 0.3);
margin-top: 10px;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.5);
}
.btn-submit:disabled {
background: #1e293b;
color: #475569;
box-shadow: none;
cursor: not-allowed;
}
.submit-icon {
width: 18px;
height: 18px;
}
/* Campaign Stats Box */
.stats-box-card {
background: linear-gradient(135deg, rgba(6, 17, 38, 0.7), rgba(3, 8, 20, 0.7));
border-color: rgba(0, 210, 255, 0.15);
}
.simple-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.simple-stat-row:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.simple-lbl {
font-size: 0.9rem;
color: #94a3b8;
font-weight: 600;
}
.simple-val {
font-size: 1.15rem;
font-weight: 800;
color: white;
}
.accent-text {
color: var(--primary-color);
text-shadow: 0 0 10px rgba(0, 210, 255, 0.3);
}
/* Feed Card */
.feed-card {
flex: 1;
}
.donation-feed {
display: flex;
flex-direction: column;
gap: 15px;
max-height: 380px;
overflow-y: auto;
padding-right: 5px;
}
/* Scrollbar styling */
.donation-feed::-webkit-scrollbar {
width: 4px;
}
.donation-feed::-webkit-scrollbar-thumb {
background: rgba(0, 210, 255, 0.2);
border-radius: 2px;
}
.feed-item {
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 15px;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 0.3s ease;
}
.feed-item:hover {
border-color: rgba(0, 210, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.feed-name {
font-size: 0.9rem;
font-weight: 800;
color: white;
}
.feed-amt {
font-size: 0.85rem;
font-weight: 700;
color: #ff6b6b;
}
.feed-msg {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
line-height: 1.4;
font-style: italic;
}
.feed-time {
font-size: 0.75rem;
color: #475569;
align-self: flex-end;
}
.empty-feed {
padding: 40px;
text-align: center;
color: #475569;
font-size: 0.9rem;
font-weight: 600;
}
/* Success Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(2, 6, 17, 0.8);
backdrop-filter: blur(8px);
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
background: radial-gradient(circle at center, #061530 0%, #030814 100%);
border: 1px solid rgba(0, 210, 255, 0.25);
max-width: 500px;
width: 100%;
border-radius: 24px;
padding: 40px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 20px 50px rgba(0, 210, 255, 0.3);
}
.success-big-icon {
width: 70px;
height: 70px;
color: var(--primary-color);
margin-bottom: 20px;
animation: scale-up-bounce 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes scale-up-bounce {
0% { transform: scale(0.3); opacity: 0; }
70% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
.modal-title {
font-size: 1.6rem;
font-weight: 800;
color: white;
margin: 0 0 10px 0;
}
.modal-desc {
font-size: 0.95rem;
color: #94a3b8;
line-height: 1.6;
margin: 0 0 30px 0;
}
.btn-modal-close {
width: 120px;
}
/* Transitions */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-30px);
}
/* Responsive */
@media (max-width: 992px) {
.donation-grid {
grid-template-columns: 1fr;
}
}
</style>
+585
View File
@@ -0,0 +1,585 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Anchor, Users, Fish, Trash2, HelpCircle, ChevronRight, Waves } from 'lucide-vue-next'
const router = useRouter()
const stats = ref({
totalDonors: 12543,
totalAmount: 627150000,
releasedFish: 4200000,
collectedWaste: 185000,
purifiedArea: 95000
})
onMounted(async () => {
try {
// Dynamic import to handle non-Wails preview mode gracefully
// @ts-ignore
const CampaignService = await import('../../bindings/changeme/campaignservice')
const res = await CampaignService.GetStats()
if (res) {
stats.value = res
}
} catch (e) {
console.warn('Wails bindings not available, using mock statistics.', e)
}
})
const formatNumber = (num: number) => {
return new Intl.NumberFormat('ko-KR').format(num)
}
const formatKoreanAmount = (num: number) => {
if (num >= 100000000) {
const eok = Math.floor(num / 100000000)
const man = Math.floor((num % 100000000) / 10000)
return `${eok}${man > 0 ? man + '만' : ''}`
}
return formatNumber(num) + '원'
}
</script>
<template>
<div class="home-container">
<!-- Hero Section -->
<section class="hero-section">
<div class="ocean-bg">
<div class="wave wave1"></div>
<div class="wave wave2"></div>
<div class="wave wave3"></div>
<div class="light-ray"></div>
<div class="bubbles">
<div v-for="n in 15" :key="n" class="bubble" :style="`--i:${n}; --s:${Math.random() * 1.5 + 0.5}`"></div>
</div>
</div>
<div class="hero-content">
<span class="hero-badge"><Waves class="badge-icon" /> 수협중앙회 공익 캠페인</span>
<h1 class="hero-title">
생명의 바다,<br />
<span class="highlight">희망을 품다</span>
</h1>
<p class="hero-desc">
바다는 어업인의 일터이자 인류 생명의 젖줄입니다.<br />
수협중앙회 '희망의 바다 만들기 운동' 함께 깨끗하고 풍요로운 바다 생태계를 미래 세대에게 물려주세요.
</p>
<div class="hero-actions">
<button class="btn btn-primary" @click="router.push('/donation')">
<span>캠페인 동참하기</span>
<HeartHandshake class="btn-icon" />
</button>
<button class="btn btn-secondary" @click="router.push('/about')">
<span>자세히 알아보기</span>
<ChevronRight class="btn-icon" />
</button>
</div>
</div>
</section>
<!-- Stats Dashboard Section -->
<section class="stats-section">
<div class="section-header">
<h2 class="section-title">희망의 바다 누적 성과</h2>
<p class="section-subtitle">2007년부터 수협중앙회와 전국의 어업인이 흘려 가꿔온 생명의 기록입니다.</p>
</div>
<div class="stats-grid">
<!-- Card 1: Donors -->
<div class="stat-card">
<div class="stat-icon-wrapper donor-icon">
<Users class="stat-icon" />
</div>
<div class="stat-info">
<span class="stat-label">누적 참여 후원자</span>
<span class="stat-number">{{ formatNumber(stats.totalDonors) }} <span class="stat-unit"></span></span>
</div>
<div class="stat-progress">
<div class="progress-bar" style="width: 82%"></div>
</div>
<p class="stat-desc">많은 시민과 어업인이 한마음으로 동참하고 있습니다.</p>
</div>
<!-- Card 2: Released Fish -->
<div class="stat-card">
<div class="stat-icon-wrapper fish-icon">
<Fish class="stat-icon" />
</div>
<div class="stat-info">
<span class="stat-label">방류된 수산종자</span>
<span class="stat-number">{{ formatNumber(stats.releasedFish) }} <span class="stat-unit">마리</span></span>
</div>
<div class="stat-progress">
<div class="progress-bar" style="width: 75%"></div>
</div>
<p class="stat-desc">조피볼락, 감성돔 우수 수산종자를 바다로 돌려보냈습니다.</p>
</div>
<!-- Card 3: Waste -->
<div class="stat-card">
<div class="stat-icon-wrapper waste-icon">
<Trash2 class="stat-icon" />
</div>
<div class="stat-info">
<span class="stat-label">수거된 해양 쓰레기</span>
<span class="stat-number">{{ formatNumber(stats.collectedWaste) }} <span class="stat-unit">kg</span></span>
</div>
<div class="stat-progress">
<div class="progress-bar" style="width: 90%"></div>
</div>
<p class="stat-desc">조업 인양 폐기물과 침적 폐어구를 집중 정화했습니다.</p>
</div>
<!-- Card 4: Purified Area -->
<div class="stat-card">
<div class="stat-icon-wrapper area-icon">
<Anchor class="stat-icon" />
</div>
<div class="stat-info">
<span class="stat-label">정화된 해역 면적</span>
<span class="stat-number">{{ formatNumber(stats.purifiedArea) }} <span class="stat-unit"></span></span>
</div>
<div class="stat-progress">
<div class="progress-bar" style="width: 68%"></div>
</div>
<p class="stat-desc">갯녹음 해역 복원 바닥갈이 사업을 진행했습니다.</p>
</div>
</div>
<!-- General Info Alert -->
<div class="total-donation-badge">
<span class="badge-label">희망의 바다 조성 기금 누적액</span>
<span class="badge-amount">{{ formatKoreanAmount(stats.totalAmount) }}</span>
</div>
</section>
<!-- Call to action Section -->
<section class="cta-section">
<div class="cta-card">
<h3 class="cta-title">어업인과 국민이 함께 해양 생태계를 살립니다</h3>
<p class="cta-desc">
풍요로운 어장 복원과 맑은 바다 환경 조성은 우리 모두의 의무입니다. <br />
당신의 작은 실천이 푸른 바다의 희망찬 미래를 만들어 냅니다.
</p>
<button class="btn btn-primary" @click="router.push('/donation')">지금 바로 후원하기</button>
</div>
</section>
</div>
</template>
<style scoped>
.home-container {
display: flex;
flex-direction: column;
gap: 80px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
/* Hero Section */
.hero-section {
position: relative;
display: flex;
align-items: center;
min-height: 550px;
padding: 40px;
border-radius: 24px;
overflow: hidden;
border: 1px solid var(--glass-border);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.ocean-bg {
position: absolute;
inset: 0;
background: linear-gradient(180deg, #021a3a 0%, #030814 100%);
z-index: 1;
overflow: hidden;
}
/* Waves animation */
.wave {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath d='M321.39,56.44c58-10.79,114.16-30.13,172-41.86,82.39-16.72,168.19-17.73,250.45-.39C823.78,31,906.67,72,985.66,92.83c70.05,18.48,146.53,26.09,214.34,3V120H0V0C26.9,8.75,55.05,18.06,83.4,26.8,145.42,46,212.41,60.11,321.39,56.44Z' fill='%2300d2ff' fill-opacity='0.08'/%3E%3C/svg%3E") repeat-x;
transform: translate3d(0, 0, 0);
}
.wave1 {
animation: wave 20s linear infinite;
z-index: 2;
}
.wave2 {
animation: wave 12s linear infinite reverse;
opacity: 0.5;
z-index: 3;
bottom: 10px;
}
.wave3 {
animation: wave 8s linear infinite;
opacity: 0.2;
z-index: 4;
bottom: 20px;
}
@keyframes wave {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* Underwater Light Ray */
.light-ray {
position: absolute;
top: 0;
left: 30%;
width: 40%;
height: 100%;
background: linear-gradient(135deg, rgba(0, 210, 255, 0.15) 0%, transparent 70%);
transform: skewX(-25deg);
filter: blur(40px);
pointer-events: none;
animation: shimmer 6s ease-in-out infinite alternate;
}
@keyframes shimmer {
0% { opacity: 0.5; transform: skewX(-20deg) translateX(-10px); }
100% { opacity: 1; transform: skewX(-30deg) translateX(10px); }
}
/* Bubbles */
.bubble {
position: absolute;
bottom: -20px;
background: rgba(0, 210, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 50%;
width: calc(var(--s) * 15px);
height: calc(var(--s) * 15px);
left: calc(var(--i) * 6.5%);
animation: bubble-up calc(10s / var(--s)) linear infinite;
animation-delay: calc(var(--i) * 0.4s);
}
@keyframes bubble-up {
0% {
transform: translateY(0) scale(1) translateX(0);
opacity: 0;
}
10% {
opacity: 0.6;
}
90% {
opacity: 0.6;
}
100% {
transform: translateY(-600px) scale(1.2) translateX(30px);
opacity: 0;
}
}
.hero-content {
position: relative;
z-index: 10;
max-width: 600px;
text-align: left;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(0, 210, 255, 0.12);
border: 1px solid rgba(0, 210, 255, 0.3);
border-radius: 99px;
font-size: 0.85rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 24px;
}
.badge-icon {
width: 14px;
height: 14px;
}
.hero-title {
font-size: clamp(2.2rem, 5vw, 3.4rem);
line-height: 1.2;
font-weight: 800;
color: white;
margin: 0 0 20px 0;
}
.hero-title .highlight {
background: linear-gradient(to right, #00d2ff, #0066ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-desc {
font-size: 1.05rem;
line-height: 1.6;
color: #94a3b8;
margin-bottom: 35px;
}
.hero-actions {
display: flex;
gap: 15px;
}
.btn {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
border-radius: 12px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
border: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
box-shadow: 0 5px 15px rgba(0, 210, 255, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 210, 255, 0.5);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
.btn-icon {
width: 18px;
height: 18px;
}
/* Stats Section */
.stats-section {
display: flex;
flex-direction: column;
gap: 40px;
}
.section-header {
text-align: center;
}
.section-title {
font-size: 2rem;
font-weight: 800;
margin: 0 0 10px 0;
background: linear-gradient(to right, #ffffff, #94a3b8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.section-subtitle {
color: #64748b;
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.stat-card {
background: rgba(6, 17, 38, 0.4);
border: 1px solid rgba(0, 210, 255, 0.08);
border-radius: 20px;
padding: 30px;
display: flex;
flex-direction: column;
align-items: flex-start;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.stat-card:hover {
transform: translateY(-5px);
border-color: rgba(0, 210, 255, 0.25);
box-shadow: 0 10px 30px rgba(0, 210, 255, 0.1);
}
.stat-icon-wrapper {
padding: 12px;
border-radius: 12px;
margin-bottom: 20px;
}
.stat-icon {
width: 24px;
height: 24px;
color: white;
}
.donor-icon {
background: rgba(0, 210, 255, 0.15);
}
.fish-icon {
background: rgba(0, 102, 255, 0.15);
}
.waste-icon {
background: rgba(255, 107, 107, 0.15);
}
.area-icon {
background: rgba(168, 85, 247, 0.15);
}
.stat-info {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 15px;
width: 100%;
}
.stat-label {
font-size: 0.9rem;
color: #64748b;
font-weight: 600;
}
.stat-number {
font-size: 1.8rem;
font-weight: 800;
color: white;
letter-spacing: -0.5px;
}
.stat-unit {
font-size: 1rem;
color: #94a3b8;
font-weight: 500;
}
.stat-progress {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
margin-bottom: 15px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
border-radius: 3px;
}
.stat-desc {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
line-height: 1.4;
}
.total-donation-badge {
align-self: center;
display: flex;
align-items: center;
gap: 15px;
padding: 16px 30px;
background: linear-gradient(135deg, rgba(6, 17, 38, 0.8), rgba(3, 8, 20, 0.8));
border: 1px solid rgba(0, 210, 255, 0.2);
border-radius: 99px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.badge-label {
font-size: 0.95rem;
color: #94a3b8;
font-weight: 600;
}
.badge-amount {
font-size: 1.4rem;
font-weight: 800;
color: var(--primary-color);
text-shadow: 0 0 10px rgba(0, 210, 255, 0.4);
}
/* CTA Section */
.cta-section {
width: 100%;
}
.cta-card {
background: radial-gradient(circle at top right, rgba(0, 210, 255, 0.15) 0%, rgba(3, 8, 20, 0.8) 70%);
border: 1px solid rgba(0, 210, 255, 0.15);
border-radius: 24px;
padding: 50px 40px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.cta-title {
font-size: 1.8rem;
font-weight: 800;
color: white;
margin: 0;
}
.cta-desc {
font-size: 1.05rem;
line-height: 1.6;
color: #94a3b8;
max-width: 700px;
margin: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.hero-section {
padding: 30px 20px;
min-height: auto;
}
.hero-actions {
flex-direction: column;
gap: 10px;
}
.btn {
width: 100%;
justify-content: center;
}
.total-donation-badge {
flex-direction: column;
gap: 5px;
border-radius: 20px;
width: 100%;
text-align: center;
}
.cta-card {
padding: 35px 20px;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"noUnusedParameters": false,
"noImplicitAny": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "bindings"],
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import wails from "@wailsio/runtime/plugins/vite";
// https://vitejs.dev/config/
export default defineConfig({
server: {
host: "127.0.0.1",
port: Number(process.env.WAILS_VITE_PORT) || 9245,
strictPort: true,
},
plugins: [vue(), wails("./bindings")],
});