first commit
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
/* BEGIN USAGE */
|
||||
/**
|
||||
* <image-slot> — user-fillable image placeholder.
|
||||
*
|
||||
* Drop this into a deck, mockup, or page wherever you want the user to
|
||||
* supply an image. You control the slot's shape and size; the user fills it
|
||||
* by dragging an image file onto it (or clicking to browse). The dropped
|
||||
* image persists across reloads via a .image-slots.state.json sidecar —
|
||||
* same read-via-fetch / write-via-window.omelette pattern as
|
||||
* design_canvas.jsx, so the filled slot shows on share links, downloaded
|
||||
* zips, and PPTX export. Outside the omelette runtime the slot is read-only.
|
||||
*
|
||||
* The host bridge only allows sidecar writes at the project root, so the
|
||||
* HTML that uses this component is assumed to live at the project root too
|
||||
* (same constraint as design_canvas.jsx).
|
||||
*
|
||||
* Attributes:
|
||||
* id Persistence key. REQUIRED for the drop to survive reload —
|
||||
* every slot on the page needs a distinct id.
|
||||
* shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
|
||||
* 'circle' applies 50% border-radius; on a non-square slot
|
||||
* that's an ellipse — set equal width and height for a true
|
||||
* circle.
|
||||
* radius Corner radius in px for 'rounded'. (default 12)
|
||||
* mask Any CSS clip-path value. Overrides `shape` — use this for
|
||||
* hexagons, blobs, arbitrary polygons.
|
||||
* fit object-fit: cover | contain | fill. (default 'cover')
|
||||
* With cover (the default) double-clicking the filled slot
|
||||
* enters a reframe mode: the whole image spills past the mask
|
||||
* (translucent outside, opaque inside), drag to reposition,
|
||||
* corner-drag to scale. The crop persists alongside the image
|
||||
* in the sidecar. contain/fill stay static.
|
||||
* position object-position for fit=contain|fill. (default '50% 50%')
|
||||
* placeholder Empty-state caption. (default 'Drop an image')
|
||||
* src Optional initial/fallback image URL. A user drop overrides
|
||||
* it; clearing the drop reveals src again.
|
||||
*
|
||||
* Size and layout come from ordinary CSS on the element — width/height
|
||||
* inline or from a parent grid — so it composes with any layout.
|
||||
*
|
||||
* Usage:
|
||||
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
|
||||
* placeholder="Drop a hero image"></image-slot>
|
||||
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
|
||||
* <image-slot id="kite" style="width:300px;height:300px"
|
||||
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
|
||||
*/
|
||||
/* END USAGE */
|
||||
|
||||
(() => {
|
||||
const STATE_FILE = '.image-slots.state.json';
|
||||
// 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
|
||||
// sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
|
||||
const MAX_DIM = 1200;
|
||||
// Raster formats only. SVG is excluded (can carry script; createImageBitmap
|
||||
// on SVG blobs is inconsistent). GIF is excluded because the canvas
|
||||
// re-encode keeps only the first frame, so an animated GIF would silently
|
||||
// go still — better to reject than surprise.
|
||||
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
|
||||
|
||||
// ── Shared sidecar store ────────────────────────────────────────────────
|
||||
// One fetch + immediate write-on-change for every <image-slot> on the
|
||||
// page. Reads via fetch() so viewing works anywhere the HTML and sidecar
|
||||
// are served together; writes go through window.omelette.writeFile, which
|
||||
// the host allowlists to *.state.json basenames only.
|
||||
const subs = new Set();
|
||||
let slots = {};
|
||||
// ids explicitly cleared before the sidecar fetch resolved — otherwise
|
||||
// the merge below can't tell "never set" from "just deleted" and would
|
||||
// resurrect the sidecar's stale value.
|
||||
const tombstones = new Set();
|
||||
let loaded = false;
|
||||
let loadP = null;
|
||||
|
||||
function load() {
|
||||
if (loadP) return loadP;
|
||||
loadP = fetch(STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
// Merge: sidecar loses to any in-memory change that raced ahead of
|
||||
// the fetch (drop or clear) so neither is clobbered by hydration.
|
||||
if (j && typeof j === 'object') {
|
||||
const merged = Object.assign({}, j, slots);
|
||||
// A framing-only write that raced ahead of hydration must not
|
||||
// drop a user image that's only on disk — inherit u from the
|
||||
// sidecar for any in-memory entry that lacks one.
|
||||
for (const k in slots) {
|
||||
if (merged[k] && !merged[k].u && j[k]) {
|
||||
merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
|
||||
}
|
||||
}
|
||||
for (const id of tombstones) delete merged[id];
|
||||
slots = merged;
|
||||
}
|
||||
tombstones.clear();
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => { loaded = true; subs.forEach((fn) => fn()); });
|
||||
return loadP;
|
||||
}
|
||||
|
||||
// Serialize writes so two near-simultaneous drops on different slots
|
||||
// can't reorder at the backend and leave the sidecar with only the
|
||||
// first. A save requested mid-flight just marks dirty and re-fires on
|
||||
// completion with the then-current slots.
|
||||
let saving = false;
|
||||
let saveDirty = false;
|
||||
function save() {
|
||||
if (saving) { saveDirty = true; return; }
|
||||
const w = window.omelette && window.omelette.writeFile;
|
||||
if (!w) return;
|
||||
saving = true;
|
||||
Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
|
||||
.catch(() => {})
|
||||
.then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
|
||||
}
|
||||
|
||||
const S_MAX = 5;
|
||||
const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
|
||||
|
||||
// Normalize a stored slot value. Pre-reframe sidecars stored a bare
|
||||
// data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
|
||||
function getSlot(id) {
|
||||
const v = slots[id];
|
||||
if (!v) return null;
|
||||
return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
|
||||
}
|
||||
|
||||
function setSlot(id, val) {
|
||||
if (!id) return;
|
||||
if (val) { slots[id] = val; tombstones.delete(id); }
|
||||
else { delete slots[id]; if (!loaded) tombstones.add(id); }
|
||||
subs.forEach((fn) => fn());
|
||||
// A drop is rare + high-value — write immediately so nav-away can't lose
|
||||
// it. Gate on the initial read so we don't overwrite a sidecar we haven't
|
||||
// merged yet; the merge in load() keeps this change once the read lands.
|
||||
if (loaded) save(); else load().then(save);
|
||||
}
|
||||
|
||||
// ── Image downscale ─────────────────────────────────────────────────────
|
||||
// Encode through a canvas so the sidecar carries resized bytes, not the
|
||||
// raw upload. Longest side is capped at 2× the slot's rendered width
|
||||
// (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
|
||||
// for photos, so there's no need for per-image format picking.
|
||||
async function toDataUrl(file, targetW) {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
|
||||
const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
|
||||
return canvas.toDataURL('image/webp', 0.85);
|
||||
} finally {
|
||||
bitmap.close && bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom element ──────────────────────────────────────────────────────
|
||||
const stylesheet =
|
||||
':host{display:inline-block;position:relative;vertical-align:top;' +
|
||||
' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
|
||||
'.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
|
||||
// .frame img (clipped) and .spill (unclipped ghost + handles) share the
|
||||
// same left/top/width/height in frame-%, computed by _applyView(), so the
|
||||
// inside-mask crop and the outside-mask spill stay pixel-aligned.
|
||||
'.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
|
||||
' -webkit-user-drag:none;user-select:none;touch-action:none}' +
|
||||
// Reframe mode (double-click): the full image spills past the mask. The
|
||||
// spill layer is sized to the IMAGE bounds so its corners are where the
|
||||
// resize handles belong. The ghost <img> inside is translucent; the real
|
||||
// clipped <img> underneath shows the opaque in-mask crop.
|
||||
'.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
|
||||
' cursor:grab;touch-action:none}' +
|
||||
':host([data-panning]) .spill{cursor:grabbing}' +
|
||||
'.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
|
||||
' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
|
||||
' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
|
||||
'.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
|
||||
' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
|
||||
' transform:translate(-50%,-50%)}' +
|
||||
'.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
|
||||
'.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
|
||||
':host([data-reframe]){z-index:10}' +
|
||||
':host([data-reframe]) .spill{display:block}' +
|
||||
':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
|
||||
'.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
|
||||
' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
|
||||
' cursor:pointer;user-select:none}' +
|
||||
'.empty svg{opacity:.45}' +
|
||||
'.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
|
||||
'.empty .sub{font-size:11px}' +
|
||||
'.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
|
||||
'.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
|
||||
':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
|
||||
' background:rgba(201,100,66,.10)}' +
|
||||
'.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
|
||||
' transition:border-color .12s}' +
|
||||
':host([data-over]) .ring{border-color:#c96442}' +
|
||||
':host([data-filled]) .ring{display:none}' +
|
||||
// Controls sit BELOW the mask (top:100%), absolutely positioned so the
|
||||
// author-declared slot height is unaffected. The gap is padding, not a
|
||||
// top offset, so the hover target stays contiguous with the frame.
|
||||
'.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
|
||||
' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
|
||||
' white-space:nowrap}' +
|
||||
':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
|
||||
' {opacity:1;pointer-events:auto}' +
|
||||
'.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
|
||||
' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
|
||||
' backdrop-filter:blur(6px)}' +
|
||||
'.ctl button:hover{background:rgba(0,0,0,.8)}' +
|
||||
'.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
|
||||
' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
|
||||
|
||||
const icon =
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
|
||||
'<path d="m21 15-5-5L5 21"/></svg>';
|
||||
|
||||
class ImageSlot extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
// .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
|
||||
// on the frame (circle, pill, rounded) can't clip them.
|
||||
root.innerHTML =
|
||||
'<style>' + stylesheet + '</style>' +
|
||||
'<div class="frame" part="frame">' +
|
||||
' <img part="image" alt="" draggable="false" style="display:none">' +
|
||||
' <div class="empty" part="empty">' + icon +
|
||||
' <div class="cap"></div>' +
|
||||
' <div class="sub">or <u>browse files</u></div></div>' +
|
||||
' <div class="ring" part="ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="spill">' +
|
||||
' <img class="ghost" alt="" draggable="false">' +
|
||||
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
|
||||
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
|
||||
'</div>' +
|
||||
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
|
||||
' <button data-act="clear" title="Remove image">Remove</button></div>' +
|
||||
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
|
||||
this._frame = root.querySelector('.frame');
|
||||
this._ring = root.querySelector('.ring');
|
||||
this._img = root.querySelector('.frame img');
|
||||
this._empty = root.querySelector('.empty');
|
||||
this._cap = root.querySelector('.cap');
|
||||
this._sub = root.querySelector('.sub');
|
||||
this._spill = root.querySelector('.spill');
|
||||
this._ghost = root.querySelector('.ghost');
|
||||
this._err = null;
|
||||
this._input = root.querySelector('input');
|
||||
this._depth = 0;
|
||||
this._gen = 0;
|
||||
this._view = { s: 1, x: 0, y: 0 };
|
||||
this._subFn = () => this._render();
|
||||
// Shadow-DOM listeners live with the shadow DOM — bound once here so
|
||||
// disconnect/reconnect (e.g. React remount) doesn't stack handlers.
|
||||
this._empty.addEventListener('click', () => this._input.click());
|
||||
root.addEventListener('click', (e) => {
|
||||
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
|
||||
if (act === 'replace') { this._exitReframe(true); this._input.click(); }
|
||||
if (act === 'clear') {
|
||||
this._exitReframe(false);
|
||||
this._gen++;
|
||||
this._local = null;
|
||||
if (this.id) setSlot(this.id, null); else this._render();
|
||||
}
|
||||
});
|
||||
this._input.addEventListener('change', () => {
|
||||
const f = this._input.files && this._input.files[0];
|
||||
if (f) this._ingest(f);
|
||||
this._input.value = '';
|
||||
});
|
||||
// naturalWidth/Height aren't known until load — re-apply so the cover
|
||||
// baseline is computed from real dimensions, not the 100%×100% fallback.
|
||||
this._img.addEventListener('load', () => this._applyView());
|
||||
// Gated on editable + fit=cover so share links and contain/fill slots
|
||||
// stay static.
|
||||
this.addEventListener('dblclick', (e) => {
|
||||
if (!this.hasAttribute('data-editable') || !this._reframes()) return;
|
||||
e.preventDefault();
|
||||
if (this.hasAttribute('data-reframe')) this._exitReframe(true);
|
||||
else this._enterReframe();
|
||||
});
|
||||
// Pan + resize both originate on the spill layer. A handle pointerdown
|
||||
// drives an aspect-locked resize anchored at the opposite corner; any
|
||||
// other pointerdown on the spill pans. Offsets are frame-% so a
|
||||
// reframed slot survives responsive resize / PPTX export.
|
||||
this._spill.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._spill.setPointerCapture(e.pointerId);
|
||||
const rect = this.getBoundingClientRect();
|
||||
const fw = rect.width || 1, fh = rect.height || 1;
|
||||
const corner = e.target.getAttribute && e.target.getAttribute('data-c');
|
||||
let move;
|
||||
if (corner) {
|
||||
// Resize about the OPPOSITE corner. Viewport-px throughout (rect
|
||||
// fw/fh, not clientWidth) so the math survives a transform:scale()
|
||||
// ancestor — deck_stage renders slides scaled-to-fit.
|
||||
const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
|
||||
const base = Math.max(fw / iw, fh / ih);
|
||||
const sx = corner.includes('e') ? 1 : -1;
|
||||
const sy = corner.includes('s') ? 1 : -1;
|
||||
const s0 = this._view.s;
|
||||
const w0 = iw * base * s0, h0 = ih * base * s0;
|
||||
const cx0 = (50 + this._view.x) / 100 * fw;
|
||||
const cy0 = (50 + this._view.y) / 100 * fh;
|
||||
const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
|
||||
const diag0 = Math.hypot(w0, h0);
|
||||
const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
|
||||
move = (ev) => {
|
||||
const proj = (ev.clientX - rect.left - ox) * ux +
|
||||
(ev.clientY - rect.top - oy) * uy;
|
||||
const s = clampS(s0 * proj / diag0);
|
||||
const d = diag0 * s / s0;
|
||||
this._view.s = s;
|
||||
this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
|
||||
this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
} else {
|
||||
this.setAttribute('data-panning', '');
|
||||
const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
|
||||
move = (ev) => {
|
||||
this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
|
||||
this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
}
|
||||
const up = () => {
|
||||
try { this._spill.releasePointerCapture(e.pointerId); } catch {}
|
||||
this._spill.removeEventListener('pointermove', move);
|
||||
this._spill.removeEventListener('pointerup', up);
|
||||
this._spill.removeEventListener('pointercancel', up);
|
||||
this.removeAttribute('data-panning');
|
||||
this._dragUp = null;
|
||||
};
|
||||
// Stashed so _exitReframe (Escape / outside-click mid-drag) can
|
||||
// tear the capture + listeners down synchronously.
|
||||
this._dragUp = up;
|
||||
this._spill.addEventListener('pointermove', move);
|
||||
this._spill.addEventListener('pointerup', up);
|
||||
this._spill.addEventListener('pointercancel', up);
|
||||
});
|
||||
// Wheel zoom stays available inside reframe mode as a trackpad nicety —
|
||||
// zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
|
||||
this.addEventListener('wheel', (e) => {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
const r = this.getBoundingClientRect();
|
||||
const cx = (e.clientX - r.left) / r.width * 100 - 50;
|
||||
const cy = (e.clientY - r.top) / r.height * 100 - 50;
|
||||
const prev = this._view.s;
|
||||
const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
|
||||
if (next === prev) return;
|
||||
const k = next / prev;
|
||||
this._view.s = next;
|
||||
this._view.x = cx * (1 - k) + this._view.x * k;
|
||||
this._view.y = cy * (1 - k) + this._view.y * k;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Warn once per page — an id-less slot works for the session but
|
||||
// cannot persist, and two id-less slots would share nothing.
|
||||
if (!this.id && !ImageSlot._warned) {
|
||||
ImageSlot._warned = true;
|
||||
console.warn('<image-slot> without an id will not persist its dropped image.');
|
||||
}
|
||||
this.addEventListener('dragenter', this);
|
||||
this.addEventListener('dragover', this);
|
||||
this.addEventListener('dragleave', this);
|
||||
this.addEventListener('drop', this);
|
||||
subs.add(this._subFn);
|
||||
// width%/height% in _applyView encode the frame aspect at call time —
|
||||
// a host resize (responsive grid, pane divider) would stretch the
|
||||
// image until the next _render. Re-render on size change: _render()
|
||||
// re-seeds _view from stored before clamp/apply, so a shrink→grow
|
||||
// cycle round-trips instead of ratcheting x/y toward the narrower
|
||||
// frame's clamp range.
|
||||
this._ro = new ResizeObserver(() => this._render());
|
||||
this._ro.observe(this);
|
||||
load();
|
||||
this._render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
subs.delete(this._subFn);
|
||||
this.removeEventListener('dragenter', this);
|
||||
this.removeEventListener('dragover', this);
|
||||
this.removeEventListener('dragleave', this);
|
||||
this.removeEventListener('drop', this);
|
||||
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||
this._exitReframe(false);
|
||||
}
|
||||
|
||||
_enterReframe() {
|
||||
if (this.hasAttribute('data-reframe')) return;
|
||||
this.setAttribute('data-reframe', '');
|
||||
this._applyView();
|
||||
// Close on click outside (the spill handler stopPropagation()s so
|
||||
// in-image drags don't reach this) and on Escape. Listeners are held
|
||||
// on the instance so _exitReframe / disconnectedCallback can detach
|
||||
// exactly what was attached.
|
||||
this._outside = (e) => {
|
||||
if (e.composedPath && e.composedPath().includes(this)) return;
|
||||
this._exitReframe(true);
|
||||
};
|
||||
this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
|
||||
document.addEventListener('pointerdown', this._outside, true);
|
||||
document.addEventListener('keydown', this._esc, true);
|
||||
}
|
||||
|
||||
_exitReframe(commit) {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
if (this._dragUp) this._dragUp();
|
||||
this.removeAttribute('data-reframe');
|
||||
this.removeAttribute('data-panning');
|
||||
if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
|
||||
if (this._esc) document.removeEventListener('keydown', this._esc, true);
|
||||
this._outside = this._esc = null;
|
||||
if (commit) this._commitView();
|
||||
}
|
||||
|
||||
attributeChangedCallback() { if (this.shadowRoot) this._render(); }
|
||||
|
||||
// handleEvent — one listener object for all four drag events keeps the
|
||||
// add/remove symmetric and the depth counter correct.
|
||||
handleEvent(e) {
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
// Without preventDefault the browser never fires 'drop'.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
if (e.type === 'dragenter') this._depth++;
|
||||
this.setAttribute('data-over', '');
|
||||
} else if (e.type === 'dragleave') {
|
||||
// dragenter/leave fire for every descendant crossing — count depth
|
||||
// so hovering the icon inside the empty state doesn't flicker.
|
||||
if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
|
||||
} else if (e.type === 'drop') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._depth = 0;
|
||||
this.removeAttribute('data-over');
|
||||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) this._ingest(f);
|
||||
}
|
||||
}
|
||||
|
||||
async _ingest(file) {
|
||||
this._setError(null);
|
||||
if (!file || ACCEPT.indexOf(file.type) < 0) {
|
||||
this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
|
||||
return;
|
||||
}
|
||||
// toDataUrl can take hundreds of ms on a large photo. A Clear or a
|
||||
// newer drop during that window would be clobbered when this await
|
||||
// resumes — bump + capture a generation so stale encodes bail.
|
||||
const gen = ++this._gen;
|
||||
try {
|
||||
const w = this.clientWidth || this.offsetWidth || MAX_DIM;
|
||||
const url = await toDataUrl(file, w);
|
||||
if (gen !== this._gen) return;
|
||||
// Only exit reframe once the new image is in hand — a rejected type
|
||||
// or decode failure leaves the in-progress crop untouched.
|
||||
this._exitReframe(false);
|
||||
const val = { u: url, s: 1, x: 0, y: 0 };
|
||||
setSlot(this.id || '', val);
|
||||
// Keep a session-local copy for id-less slots so the drop still
|
||||
// shows, even though it cannot persist.
|
||||
if (!this.id) { this._local = val; this._render(); }
|
||||
} catch (err) {
|
||||
if (gen !== this._gen) return;
|
||||
this._setError('Could not read that image.');
|
||||
console.warn('<image-slot> ingest failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_setError(msg) {
|
||||
if (this._err) { this._err.remove(); this._err = null; }
|
||||
if (!msg) return;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'err'; d.textContent = msg;
|
||||
this.shadowRoot.appendChild(d);
|
||||
this._err = d;
|
||||
setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
|
||||
}
|
||||
|
||||
// Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
|
||||
// keep the old object-fit path and double-click is a no-op.
|
||||
_reframes() {
|
||||
return this.hasAttribute('data-filled') &&
|
||||
(this.getAttribute('fit') || 'cover') === 'cover';
|
||||
}
|
||||
|
||||
// Cover-baseline geometry, shared by clamp/apply/resize. Null until the
|
||||
// img has loaded (naturalWidth is 0 before that) or when the slot has no
|
||||
// layout box — ResizeObserver fires with a 0×0 rect under display:none,
|
||||
// and clamping against a degenerate 1×1 frame would silently pull the
|
||||
// stored pan toward zero.
|
||||
_geom() {
|
||||
const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
|
||||
const fw = this.clientWidth, fh = this.clientHeight;
|
||||
if (!iw || !ih || !fw || !fh) return null;
|
||||
return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
|
||||
}
|
||||
|
||||
_clampView() {
|
||||
// Pan range on each axis is half the overflow past the frame edge.
|
||||
const g = this._geom();
|
||||
if (!g) return;
|
||||
const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
|
||||
const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
|
||||
this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
|
||||
this._view.y = Math.max(-my, Math.min(my, this._view.y));
|
||||
}
|
||||
|
||||
_applyView() {
|
||||
const g = this._geom();
|
||||
const fit = this.getAttribute('fit') || 'cover';
|
||||
if (fit !== 'cover' || !g) {
|
||||
// Non-cover, or dimensions not known yet (before img load).
|
||||
this._img.style.width = '100%';
|
||||
this._img.style.height = '100%';
|
||||
this._img.style.left = '50%';
|
||||
this._img.style.top = '50%';
|
||||
this._img.style.objectFit = fit;
|
||||
this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
|
||||
return;
|
||||
}
|
||||
// Cover baseline: img fills the frame on its tighter axis at s=1, so
|
||||
// pan works immediately on the overflowing axis without zooming first.
|
||||
// Width/height and left/top are all frame-% — depends only on the
|
||||
// frame aspect ratio, so a responsive resize keeps the same crop. The
|
||||
// spill layer mirrors the same box so its corners = image corners.
|
||||
const k = g.base * this._view.s;
|
||||
const w = (g.iw * k / g.fw * 100) + '%';
|
||||
const h = (g.ih * k / g.fh * 100) + '%';
|
||||
const l = (50 + this._view.x) + '%';
|
||||
const t = (50 + this._view.y) + '%';
|
||||
this._img.style.width = w; this._img.style.height = h;
|
||||
this._img.style.left = l; this._img.style.top = t;
|
||||
this._img.style.objectFit = '';
|
||||
this._spill.style.width = w; this._spill.style.height = h;
|
||||
this._spill.style.left = l; this._spill.style.top = t;
|
||||
}
|
||||
|
||||
_commitView() {
|
||||
const v = { s: this._view.s, x: this._view.x, y: this._view.y };
|
||||
if (this._userUrl) v.u = this._userUrl;
|
||||
// Framing-only (no u) persists too so an author-src slot remembers its
|
||||
// crop; clearing the sidecar still falls through to src=.
|
||||
if (this.id) setSlot(this.id, v);
|
||||
else { this._local = v; }
|
||||
}
|
||||
|
||||
_render() {
|
||||
// Shape / mask. Presets use border-radius so the dashed ring can
|
||||
// follow the rounded outline; clip-path is only applied for an
|
||||
// explicit `mask` (the ring is hidden there since a rectangle
|
||||
// dashed border chopped by an arbitrary polygon looks broken).
|
||||
const mask = this.getAttribute('mask');
|
||||
const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
|
||||
let radius = '';
|
||||
if (shape === 'circle') radius = '50%';
|
||||
else if (shape === 'pill') radius = '9999px';
|
||||
else if (shape === 'rounded') {
|
||||
const n = parseFloat(this.getAttribute('radius'));
|
||||
radius = (Number.isFinite(n) ? n : 12) + 'px';
|
||||
}
|
||||
this._frame.style.borderRadius = mask ? '' : radius;
|
||||
this._frame.style.clipPath = mask || '';
|
||||
this._ring.style.borderRadius = mask ? '' : radius;
|
||||
this._ring.style.display = mask ? 'none' : '';
|
||||
|
||||
// Controls and reframe entry gate on this so share links stay read-only.
|
||||
const editable = !!(window.omelette && window.omelette.writeFile);
|
||||
this.toggleAttribute('data-editable', editable);
|
||||
this._sub.style.display = editable ? '' : 'none';
|
||||
|
||||
// Content. The sidecar is also writable by the agent's write_file
|
||||
// tool, so its value isn't guaranteed canvas-originated — only accept
|
||||
// data:image/ URLs from it. The `src` attribute is author-controlled
|
||||
// (Claude wrote it into the HTML) so it passes through unchanged.
|
||||
let stored = this.id ? getSlot(this.id) : this._local;
|
||||
if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
|
||||
const srcAttr = this.getAttribute('src') || '';
|
||||
this._userUrl = (stored && stored.u) || null;
|
||||
const url = this._userUrl || srcAttr;
|
||||
// Don't clobber an in-flight reframe with a store-triggered re-render.
|
||||
if (!this.hasAttribute('data-reframe')) {
|
||||
this._view = {
|
||||
s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
|
||||
x: stored && Number.isFinite(stored.x) ? stored.x : 0,
|
||||
y: stored && Number.isFinite(stored.y) ? stored.y : 0,
|
||||
};
|
||||
}
|
||||
this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
|
||||
// Toggle via style.display — the [hidden] attribute alone loses to
|
||||
// the display:flex / display:block rules in the stylesheet above.
|
||||
if (url) {
|
||||
if (this._img.getAttribute('src') !== url) {
|
||||
this._img.src = url;
|
||||
this._ghost.src = url;
|
||||
}
|
||||
this._img.style.display = 'block';
|
||||
this._empty.style.display = 'none';
|
||||
this.setAttribute('data-filled', '');
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
} else {
|
||||
this._img.style.display = 'none';
|
||||
this._img.removeAttribute('src');
|
||||
this._ghost.removeAttribute('src');
|
||||
this._empty.style.display = 'flex';
|
||||
this.removeAttribute('data-filled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('image-slot')) {
|
||||
customElements.define('image-slot', ImageSlot);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,242 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 클랜 데이터 + 엠블럼 렌더러
|
||||
// window.ClanDB(name) → 클랜 상세, window.Emblem, window.EMBLEM_LIB
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
(function () {
|
||||
const r = (n) => Math.floor(Math.random() * n);
|
||||
const pick = (a) => a[r(a.length)];
|
||||
|
||||
// ── 엠블럼 도형 (viewBox 100x100) ──
|
||||
const SHAPES = {
|
||||
shield: 'M50 6 L88 22 V52 C88 78 70 88 50 96 C30 88 12 78 12 52 V22 Z',
|
||||
circle: null, // 원은 <circle>로 별도 처리
|
||||
hexagon: 'M50 5 L88 27 V73 L50 95 L12 73 V27 Z',
|
||||
diamond: 'M50 5 L95 50 L50 95 L5 50 Z',
|
||||
};
|
||||
const SHAPE_KEYS = ['shield', 'circle', 'hexagon', 'diamond'];
|
||||
|
||||
// ── 엠블럼 심볼 (중심 50,50 기준) ──
|
||||
const SYM = {
|
||||
crosshair: (c) => (<g fill="none" stroke={c} strokeWidth="5" strokeLinecap="round">
|
||||
<circle cx="50" cy="50" r="17" /><line x1="50" y1="22" x2="50" y2="34" /><line x1="50" y1="66" x2="50" y2="78" />
|
||||
<line x1="22" y1="50" x2="34" y2="50" /><line x1="66" y1="50" x2="78" y2="50" /><circle cx="50" cy="50" r="3" fill={c} stroke="none" /></g>),
|
||||
star: (c) => (<path fill={c} d="M50 26 L56.5 43 L74 44 L60 55 L65 72 L50 62 L35 72 L40 55 L26 44 L43.5 43 Z" />),
|
||||
skull: (c) => (<g fill={c}><path d="M50 28 C36 28 28 38 28 50 C28 58 32 62 36 65 L36 72 L64 72 L64 65 C68 62 72 58 72 50 C72 38 64 28 50 28 Z" />
|
||||
<circle cx="41" cy="50" r="5" fill="#fff" /><circle cx="59" cy="50" r="5" fill="#fff" /><path d="M47 60 L50 56 L53 60 Z" fill="#fff" /></g>),
|
||||
wolf: (c) => (<g fill={c}><ellipse cx="50" cy="58" rx="9" ry="11" /><ellipse cx="35" cy="44" rx="5" ry="7" /><ellipse cx="65" cy="44" rx="5" ry="7" /><ellipse cx="42" cy="34" rx="4.5" ry="6" /><ellipse cx="58" cy="34" rx="4.5" ry="6" /></g>),
|
||||
sword: (c) => (<g stroke={c} strokeWidth="5" strokeLinecap="round" fill="none"><line x1="30" y1="30" x2="68" y2="68" /><line x1="70" y1="30" x2="32" y2="68" /><line x1="26" y1="40" x2="40" y2="26" /><line x1="60" y1="74" x2="74" y2="60" /></g>),
|
||||
crown: (c) => (<path fill={c} d="M28 64 L24 36 L37 48 L50 30 L63 48 L76 36 L72 64 Z" />),
|
||||
flame: (c) => (<path fill={c} d="M50 26 C58 38 66 44 66 56 C66 67 58 74 50 74 C42 74 34 67 34 56 C34 50 38 46 42 50 C42 40 46 32 50 26 Z" />),
|
||||
bolt: (c) => (<path fill={c} d="M54 24 L34 54 L47 54 L44 76 L66 44 L52 44 Z" />),
|
||||
shieldcheck: (c) => (<g><path fill={c} d="M50 26 L70 33 V52 C70 64 61 70 50 75 C39 70 30 64 30 52 V33 Z" /><path d="M42 51 L48 57 L60 43" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" /></g>),
|
||||
anchor: (c) => (<g fill="none" stroke={c} strokeWidth="5" strokeLinecap="round"><circle cx="50" cy="32" r="5" /><line x1="50" y1="37" x2="50" y2="72" /><line x1="38" y1="48" x2="62" y2="48" /><path d="M32 60 C32 70 42 74 50 74 C58 74 68 70 68 60" /></g>),
|
||||
};
|
||||
const SYM_KEYS = Object.keys(SYM);
|
||||
|
||||
const BG_COLORS = ['#48515f', '#2f6fed', '#c0392b', '#1f8a5b', '#8e44ad', '#d98a1f', '#16708a', '#33373d'];
|
||||
const SYMBOL_COLORS = ['#ffffff', '#ffe08a', '#ffd1d1', '#bfe3ff', '#c9f0d8'];
|
||||
const THEMES = ['#48515f', '#2f6fed', '#c0392b', '#1f8a5b', '#8e44ad', '#b8860b'];
|
||||
|
||||
function shade(hex, amt) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
let r0 = (n >> 16) + amt, g0 = ((n >> 8) & 255) + amt, b0 = (n & 255) + amt;
|
||||
r0 = Math.max(0, Math.min(255, r0)); g0 = Math.max(0, Math.min(255, g0)); b0 = Math.max(0, Math.min(255, b0));
|
||||
return '#' + (r0 * 65536 + g0 * 256 + b0).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
function Emblem({ cfg, size = 88, ring = true }) {
|
||||
const { shape = 'shield', bg = '#48515f', symbol = 'crosshair', symbolColor = '#ffffff' } = cfg || {};
|
||||
const grad = `eg-${shape}-${bg.slice(1)}-${Math.round(size)}`;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" style={{ flexShrink: 0, filter: 'drop-shadow(0 2px 4px rgba(0,0,0,.18))' }}>
|
||||
<defs>
|
||||
<linearGradient id={grad} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={shade(bg, 26)} /><stop offset="100%" stopColor={shade(bg, -22)} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{shape === 'circle'
|
||||
? <circle cx="50" cy="50" r="45" fill={`url(#${grad})`} stroke={ring ? '#ffffff' : 'none'} strokeWidth={ring ? 3 : 0} strokeOpacity="0.7" />
|
||||
: <path d={SHAPES[shape]} fill={`url(#${grad})`} stroke={ring ? '#ffffff' : 'none'} strokeWidth={ring ? 3 : 0} strokeOpacity="0.7" strokeLinejoin="round" />}
|
||||
{SYM[symbol] ? SYM[symbol](symbolColor) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 클랜 멤버 생성 ──
|
||||
const MEMBER_NICKS = ['불곰대장', '꿀밤한방', '연어초밥', '심야의총잡이', '각잡는곰', '리코일마스터', '플래시박사', '스팀팩중독', '한발의미학', '엄폐중독자', '연막의신', '돌격앞으로', '백발백중', '수비의화신', '클러치메이커', '에임핵아님', '곰발바닥', '겨울잠전문', '북극곰', '꿀단지수호', '재장전중', '헤드샷셰프', '각성한곰', '연승기원', '막내곰', '신입곰돌이'];
|
||||
|
||||
function genMembers(count, captain) {
|
||||
const out = []; const used = new Set();
|
||||
// 군 계급(rank) 풀 — 낮은 tier 숫자가 높은 계급
|
||||
const ranks = (window.SA && window.SA.allPlayers ? window.SA.allPlayers.map((p) => p.rank).filter(Boolean) : []);
|
||||
const uniq = []; const seenT = new Set();
|
||||
ranks.forEach((rk) => { if (!seenT.has(rk.tier)) { seenT.add(rk.tier); uniq.push(rk); } });
|
||||
uniq.sort((a, b) => a.tier - b.tier); // 높은 계급(작은 tier)부터
|
||||
const rankByRole = (role) => {
|
||||
if (!uniq.length) return null;
|
||||
const top = uniq.length;
|
||||
let lo, hi;
|
||||
if (role === '클랜장') { lo = 0; hi = Math.max(1, Math.floor(top * 0.18)); }
|
||||
else if (role === '운영진') { lo = 0; hi = Math.max(2, Math.floor(top * 0.35)); }
|
||||
else if (role === '정예') { lo = Math.floor(top * 0.2); hi = Math.floor(top * 0.6); }
|
||||
else { lo = Math.floor(top * 0.4); hi = top; }
|
||||
const idx = Math.min(top - 1, lo + r(Math.max(1, hi - lo)));
|
||||
return uniq[idx];
|
||||
};
|
||||
const plan = [];
|
||||
plan.push('클랜장');
|
||||
for (let i = 0; i < 3 && plan.length < count; i++) plan.push('운영진');
|
||||
for (let i = 0; i < 7 && plan.length < count; i++) plan.push('정예');
|
||||
while (plan.length < count) plan.push('일반');
|
||||
plan.forEach((role, i) => {
|
||||
let name;
|
||||
if (i === 0 && captain) name = captain;
|
||||
else { do { name = pick(MEMBER_NICKS); } while (used.has(name)); }
|
||||
used.add(name);
|
||||
out.push({
|
||||
name, role,
|
||||
rank: rankByRole(role),
|
||||
contrib: role === '클랜장' ? 1800 + r(900) : role === '운영진' ? 1100 + r(800) : role === '정예' ? 600 + r(700) : 60 + r(560),
|
||||
});
|
||||
});
|
||||
return out.sort((a, b) => b.contrib - a.contrib);
|
||||
}
|
||||
|
||||
// 클랜별 고정 시드(이름 기반)로 엠블럼/테마 결정 → 재방문 시 동일
|
||||
function seedFromName(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }
|
||||
|
||||
const BASE = {
|
||||
'불곰부대': { tag: 'BEAR', members: 52, wr: 68, rank: 2, captain: '서든황제김씨', emblem: { shape: 'shield', bg: '#8a5a2b', symbol: 'wolf', symbolColor: '#ffe08a' }, theme: '#8a5a2b',
|
||||
slogan: '겨울잠은 시즌 끝나고. 지금은 사냥철.', intro: '러시아워 정규전 위주로 빡겜과 친목을 동시에 챙기는 곰 군단입니다. 매너 좋은 복귀 유저, 성장하고 싶은 뉴비 모두 환영해요.' },
|
||||
'서울제일': { tag: 'SEL', members: 48, wr: 71, rank: 1, captain: 'GLOCK_여신', emblem: { shape: 'hexagon', bg: '#2f6fed', symbol: 'crown', symbolColor: '#ffe08a' }, theme: '#2f6fed',
|
||||
slogan: '제일이 아니면 의미가 없다.', intro: '시즌2 1위 수성 중인 경쟁 지향 클랜. 정예 위주 스크림, 대회 출전 클랜입니다.' },
|
||||
'야간전투': { tag: 'NGT', members: 41, wr: 66, rank: 3, captain: '컨테이너지박령', emblem: { shape: 'circle', bg: '#33373d', symbol: 'bolt', symbolColor: '#bfe3ff' }, theme: '#33373d',
|
||||
slogan: '해가 지면, 우리의 시간.', intro: '심야 스크림 전문. 새벽까지 함께 달릴 올빼미 환영.' },
|
||||
'한강수비대': { tag: 'HAN', members: 45, wr: 64, rank: 4, captain: '데드폴터줏대', emblem: { shape: 'shield', bg: '#16708a', symbol: 'shieldcheck', symbolColor: '#ffffff' }, theme: '#16708a',
|
||||
slogan: '뚫리지 않는다. 우리가 강이니까.', intro: '주말 정모 클랜전 중심의 안정적인 운영. 꾸준한 활동러 환영.' },
|
||||
'벚꽃클랜': { tag: 'SAK', members: 38, wr: 62, rank: 5, captain: '오로라공주', emblem: { shape: 'diamond', bg: '#b14d7a', symbol: 'flame', symbolColor: '#ffd1d1' }, theme: '#b14d7a',
|
||||
slogan: '피고 지는 건 벚꽃, 지지 않는 건 우리.', intro: '뉴비 멘토링 ◎ 친목과 즐겜 위주의 따뜻한 클랜이에요.' },
|
||||
'강철군화': { tag: 'STL', members: 50, wr: 61, rank: 6, captain: '원킬원샷', emblem: { shape: 'hexagon', bg: '#48515f', symbol: 'sword', symbolColor: '#ffffff' }, theme: '#48515f',
|
||||
slogan: '매일 21시, 군화 끈을 조여라.', intro: '매일 정기 클랜전 운영. 규율 있는 활동을 선호하는 분께 추천.' },
|
||||
};
|
||||
|
||||
const _cache = {};
|
||||
function ClanDB(name) {
|
||||
const key = name || '불곰부대';
|
||||
if (_cache[key]) return _cache[key];
|
||||
const b = BASE[key] || {
|
||||
tag: (key.slice(0, 3) || 'CLN').toUpperCase(), members: 30 + r(25), wr: 55 + r(16), rank: 7 + r(40), captain: pick(MEMBER_NICKS),
|
||||
emblem: { shape: pick(SHAPE_KEYS), bg: pick(BG_COLORS), symbol: pick(SYM_KEYS), symbolColor: pick(SYMBOL_COLORS) }, theme: pick(THEMES),
|
||||
slogan: '함께라서 강하다.', intro: '함께 성장할 클랜원을 찾고 있어요. 분위기 좋은 우리 클랜에 놀러오세요!',
|
||||
};
|
||||
const seed = seedFromName(key);
|
||||
const members = genMembers(Math.min(b.members, 24), b.captain);
|
||||
const data = {
|
||||
name: key, tag: b.tag, slogan: b.slogan, intro: b.intro,
|
||||
emblem: { ...b.emblem }, theme: b.theme,
|
||||
level: 12 + (seed % 20), exp: 40 + (seed % 55), expMax: 100,
|
||||
expTotal: 80000 + (seed % 90000), expWeek: 1200 + (seed % 4200),
|
||||
founded: `${2021 + (seed % 4)}.${String(1 + (seed % 11)).padStart(2, '0')}.${String(1 + (seed % 27)).padStart(2, '0')}`,
|
||||
members, memberMax: b.members, totalMembers: b.members,
|
||||
weeklyWr: b.wr, rank: b.rank,
|
||||
notices: [
|
||||
{ pin: true, title: '이번 주 정기전 일정 안내 — 목/토 21시', author: b.captain, time: '2시간 전' },
|
||||
{ pin: false, title: '신규 클랜원 환영합니다! 인사 남겨주세요', author: pick(MEMBER_NICKS), time: '1일 전' },
|
||||
{ pin: false, title: '클랜 보이스 채널 새로 팠어요 (디스코드)', author: pick(MEMBER_NICKS), time: '2일 전' },
|
||||
{ pin: false, title: '클랜전 출전 명단 신청 받습니다 (이번 주)', author: b.captain, time: '3일 전' },
|
||||
{ pin: false, title: '주말 내전 결과 정리 및 하이라이트', author: pick(MEMBER_NICKS), time: '4일 전' },
|
||||
{ pin: false, title: '장기 미접속 클랜원 정리 예고 안내', author: b.captain, time: '5일 전' },
|
||||
{ pin: false, title: '클랜 규칙 일부 개정 — 필독 부탁드려요', author: b.captain, time: '1주 전' },
|
||||
],
|
||||
feed: [
|
||||
{ author: members[0].name, role: members[0].role, text: '오늘 정기전 5승 0패! 다들 폼 미쳤다 🔥', time: '38분 전', likes: 24 },
|
||||
{ author: members[2] ? members[2].name : '정예곰', role: '정예', text: '신맵 데드폴 수비 루트 정리해서 공지에 올려둠. 한번씩 보기!', time: '3시간 전', likes: 17 },
|
||||
{ author: members[4] ? members[4].name : '막내곰', role: '일반', text: '가입한 지 일주일 됐는데 다들 너무 친절하세요 ㅎㅎ 잘부탁드립니다', time: '6시간 전', likes: 31 },
|
||||
{ author: members[1] ? members[1].name : '운영곰', role: members[1] ? members[1].role : '운영진', text: '오늘 저녁 9시 보이스 모여요. 클랜전 전략 회의 있습니다.', time: '9시간 전', likes: 12 },
|
||||
{ author: members[3] ? members[3].name : '정예곰2', role: '정예', text: '어제 매치 하이라이트 클립 올렸어요. 마지막 1대3 클러치 꼭 보세요', time: '1일 전', likes: 28 },
|
||||
{ author: members[5] ? members[5].name : '일반곰', role: '일반', text: '이번 주 출석 이벤트 다들 참여하셨나요? 마감 임박!', time: '1일 전', likes: 9 },
|
||||
{ author: members[0].name, role: members[0].role, text: '지난 주 MVP 투표 결과 공유합니다. 축하해주세요 👏', time: '2일 전', likes: 35 },
|
||||
],
|
||||
guestbook: [
|
||||
{ author: '지나가던행인', text: '클랜 분위기 좋아보여요 👍', time: '1일 전', replies: [{ author: b.captain, text: '좋게 봐주셔서 감사합니다! 언제든 놀러오세요 🙂', time: '1일 전' }] },
|
||||
{ author: '스카우터', text: '혹시 정기전 용병 한 자리 가능할까요?', time: '2일 전', replies: [] },
|
||||
{ author: '복귀유저A', text: '오랜만에 복귀했는데 가입 문의는 어디로 하면 되나요?', time: '3일 전', replies: [{ author: b.captain, text: '모집 광장의 저희 모집글에서 디스코드로 문의 주세요!', time: '3일 전' }] },
|
||||
{ author: '옆클랜원', text: '지난 클랜전 잘 봤습니다. 다음에 또 붙어요!', time: '4일 전', replies: [] },
|
||||
{ author: '구경꾼', text: '엠블럼 멋지네요 ㅎㅎ', time: '5일 전', replies: [] },
|
||||
{ author: '뉴비방문자', text: '초보도 받아주시나요? 열심히 할 자신 있습니다!', time: '6일 전', replies: [] },
|
||||
],
|
||||
schedule: [
|
||||
{ day: '월', time: '21:00', title: '자유 스크림', on: false },
|
||||
{ day: '화', time: '—', title: '휴식', on: false },
|
||||
{ day: '수', time: '21:00', title: '내전 데이', on: true },
|
||||
{ day: '목', time: '21:00', title: '정기 클랜전', on: true },
|
||||
{ day: '금', time: '22:00', title: '친선전', on: false },
|
||||
{ day: '토', time: '21:00', title: '정기 클랜전', on: true },
|
||||
{ day: '일', time: '20:00', title: '주간 결산', on: true },
|
||||
],
|
||||
attendance: Array.from({ length: 7 }, (_, i) => 55 + ((seed >> i) % 42)),
|
||||
activity: Array.from({ length: 7 }, (_, i) => {
|
||||
const s2 = (seed * (i + 3)) % 9973;
|
||||
const wk = i >= 5 ? 1.6 : 1; // 주말 가중
|
||||
return {
|
||||
clanWar: Math.round((3 + (s2 % 8)) * wk),
|
||||
clanRanked: Math.round((2 + ((s2 >> 1) % 7)) * wk),
|
||||
soloRanked: Math.round((4 + ((s2 >> 2) % 10)) * wk),
|
||||
partyRanked: Math.round((3 + ((s2 >> 4) % 8)) * wk),
|
||||
};
|
||||
}),
|
||||
isMine: key === '불곰부대',
|
||||
};
|
||||
_cache[key] = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
window.Emblem = Emblem;
|
||||
window.ClanDB = ClanDB;
|
||||
window.__SYM_PREVIEW = SYM;
|
||||
window.EMBLEM_LIB = { SHAPE_KEYS, SYM_KEYS, BG_COLORS, SYMBOL_COLORS, THEMES };
|
||||
|
||||
// ── 모집 광장 데이터 ──
|
||||
const RECRUIT_NAMES = ['불곰부대', '서울제일', '야간전투', '한강수비대', '벚꽃클랜', '강철군화', '한밤의늑대', '새벽기습대', '데드샷연합', '폭풍전야', '은하수비대', '정예타격대', '코리아게이밍', '불꽃군단', '심야의사냥꾼'];
|
||||
const RECRUIT_TYPES = ['클랜전', 'A보급', '3보급', '랭크전', '상관없음'];
|
||||
window.RECRUIT_TYPES = RECRUIT_TYPES;
|
||||
const _recruits = [];
|
||||
function buildRecruits() {
|
||||
if (_recruits.length) return _recruits;
|
||||
RECRUIT_NAMES.forEach((nm) => {
|
||||
const c = ClanDB(nm);
|
||||
const seed = seedFromName(nm);
|
||||
const max = c.memberMax;
|
||||
const cur = Math.max(8, max - (3 + (seed % 12)));
|
||||
const openings = max - cur;
|
||||
const matches = 120 + (seed % 380);
|
||||
const w = Math.round(matches * c.weeklyWr / 100);
|
||||
_recruits.push({
|
||||
name: nm, emblem: c.emblem, intro: c.intro,
|
||||
weeklyWr: c.weeklyWr, rank: c.rank,
|
||||
recruitTypes: (() => { const n = 1 + (seed % 3); const out = []; for (let i = 0; i < n; i++) { const t = RECRUIT_TYPES[(seed + i * 2) % RECRUIT_TYPES.length]; if (!out.includes(t)) out.push(t); } return out; })(),
|
||||
record: { matches, w, l: matches - w },
|
||||
conditions: (() => {
|
||||
const ageMin = [null, 20, 25, 30][seed % 4];
|
||||
const ageMax = (seed >> 2) % 3 === 0 ? (ageMin ? ageMin + 10 + (seed % 6) : 35) : null;
|
||||
return {
|
||||
ageMin, ageMax,
|
||||
voice: seed % 4 !== 0,
|
||||
nickChange: seed % 3 === 0,
|
||||
noDualClan: seed % 2 === 0,
|
||||
friendJoin: seed % 3 !== 1,
|
||||
};
|
||||
})(),
|
||||
contacts: [
|
||||
{ type: '디스코드', label: '디스코드로 문의' },
|
||||
...(seed % 3 !== 0 ? [{ type: '오픈카톡', label: '오픈카톡으로 문의' }] : []),
|
||||
],
|
||||
cur, max, openings,
|
||||
hot: 40 + (seed % 220), createdAgo: 1 + (seed % 46),
|
||||
});
|
||||
});
|
||||
return _recruits;
|
||||
}
|
||||
window.ClanRecruits = buildRecruits;
|
||||
})();
|
||||
@@ -0,0 +1,457 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 클랜 모집 광장 (모집 공고 게시판)
|
||||
// window.ClanPlaza · 의존: Nav, Panel, Icon, Emblem, ClanRecruits, ClanDB
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const PZPT = '#48515f';
|
||||
const PZCH = "'Chakra Petch',sans-serif";
|
||||
function pzAgeLabel(cd) {
|
||||
if (cd.ageMin && cd.ageMax) return `${cd.ageMin}~${cd.ageMax}세`;
|
||||
if (cd.ageMin) return `${cd.ageMin}세 이상`;
|
||||
if (cd.ageMax) return `${cd.ageMax}세 이하`;
|
||||
return '전연령';
|
||||
}
|
||||
|
||||
function ContactGlyph({ type }) {
|
||||
if (type === '디스코드') {
|
||||
return (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="#fff" aria-hidden="true">
|
||||
<path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.25.5a14.6 14.6 0 0 1 4.3 1.4c-2-1-4.2-1.5-6.45-1.5s-4.45.5-6.45 1.5A14.6 14.6 0 0 1 10.85 3.5L10.6 3a19.8 19.8 0 0 0-4.9 1.4C2.6 9 1.75 13.4 2.15 17.75A19.9 19.9 0 0 0 8.2 20.8l.5-.85a13 13 0 0 1-2-1l.5-.36a14.2 14.2 0 0 0 12.1 0l.5.36c-.62.4-1.3.74-2 1l.5.85a19.9 19.9 0 0 0 6.05-3.05c.5-5-0.86-9.36-3.85-13.35ZM9 15.3c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.82.9 1.8 2c0 1.1-.8 2-1.8 2Zm6 0c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.82.9 1.8 2c0 1.1-.8 2-1.8 2Z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="#191600" aria-hidden="true">
|
||||
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9-.18.65-.66 2.35-.76 2.72 0 0-.02.13.07.18.08.05.2 0 .2 0 .26-.04 2.96-1.96 3.5-2.34.58.08 1.18.13 1.79.13 5 0 9-3.1 9-7C21 7.1 17 4 12 4Z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RecruitCard({ rc, onOpen, onDetail }) {
|
||||
const fill = Math.round((rc.cur / rc.max) * 100);
|
||||
const isMine = rc.name === (window.SA.me.clan || '');
|
||||
return (
|
||||
<div onClick={() => onDetail(rc)} style={{ background: '#fff', border: `1px solid ${isMine ? PZPT : 'var(--line)'}`, borderRadius: 8, overflow: 'hidden', display: 'flex', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,.04)', transition: 'box-shadow .15s' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.boxShadow = '0 4px 14px rgba(0,0,0,.1)'} onMouseLeave={(e) => e.currentTarget.style.boxShadow = '0 1px 2px rgba(0,0,0,.04)'}>
|
||||
{/* 홍보 포스터 (3:4, 유저 업로드) */}
|
||||
<div style={{ width: 150, flexShrink: 0, alignSelf: 'stretch', borderRight: '1px solid var(--line)', background: '#f4f5f7' }}>
|
||||
<image-slot id={`recruit-poster-${rc.name}`} shape="rect" placeholder="홍보 포스터" style={{ display: 'block', width: '100%', height: '100%' }}></image-slot>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, padding: '13px 15px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<div style={{ flexShrink: 0 }}><window.Emblem cfg={rc.emblem} size={30} /></div>
|
||||
<span style={{ fontSize: 15.5, fontWeight: 800, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{rc.name}</span>
|
||||
{isMine && <span style={{ flexShrink: 0, fontSize: 9.5, fontWeight: 700, color: PZPT, border: `1px solid ${PZPT}`, borderRadius: 3, padding: '1px 6px' }}>내 모집글</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, display: 'flex', gap: 5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{rc.recruitTypes.map((t) => (
|
||||
<span key={t} style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', background: '#fff', padding: '2px 8px', borderRadius: 4 }}>{t}</span>
|
||||
))}
|
||||
{(() => {
|
||||
const cd = rc.conditions;
|
||||
const age = pzAgeLabel(cd);
|
||||
return [age !== '전연령' ? age : null, cd.voice ? '보이스 채팅 필수' : null].filter(Boolean).map((b) => (
|
||||
<span key={b} style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--t2)', background: '#eef0f2', padding: '2px 8px', borderRadius: 4 }}>{b}</span>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, lineHeight: 1.5, color: '#4a505a', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{rc.intro}</p>
|
||||
|
||||
<div style={{ marginTop: 'auto', paddingTop: 10, display: 'flex', alignItems: 'center', gap: 9, fontSize: 11.5, color: 'var(--t2)', flexWrap: 'wrap' }}>
|
||||
<span><b style={{ color: PZPT, fontFamily: PZCH, fontWeight: 700 }}>{rc.rank}위</b></span>
|
||||
<span style={{ color: 'var(--line2)' }}>·</span>
|
||||
<span>승률 <b style={{ color: 'var(--ink)', fontFamily: PZCH }}>{rc.weeklyWr}%</b></span>
|
||||
<span style={{ color: 'var(--line2)' }}>·</span>
|
||||
<span style={{ fontFamily: PZCH }}>{rc.record.matches}전 <b style={{ color: PZPT }}>{rc.record.w}승</b> <b style={{ color: '#e5484d' }}>{rc.record.l}패</b></span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', fontSize: 11, marginBottom: 4 }}>
|
||||
<span style={{ color: 'var(--t3)', fontWeight: 600 }}>클랜원</span>
|
||||
<span style={{ fontFamily: PZCH, fontWeight: 700, color: 'var(--ink)' }}>{rc.cur}<span style={{ color: 'var(--t3)' }}>/{rc.max}</span> <span style={{ color: rc.openings <= 3 ? '#e5484d' : PZPT, fontWeight: 700 }}>· {rc.openings}자리</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: '#d4d8de', borderRadius: 4, overflow: 'hidden', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.05)' }}>
|
||||
<div style={{ width: `${fill}%`, height: '100%', background: rc.openings <= 3 ? '#e5484d' : PZPT, borderRadius: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={(e) => { e.stopPropagation(); }} style={{ marginTop: 11, padding: '8px 0', borderRadius: 6, border: 'none', background: PZPT, color: '#fff', fontWeight: 700, fontSize: 12.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>가입 신청</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 상세 모집글 ──
|
||||
function RecruitDetail({ rc, onClose, onOpenClan }) {
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 240, background: 'rgba(20,22,26,.62)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 20px', overflowY: 'auto', animation: 'sgFade .2s ease' }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 860, maxWidth: '100%', margin: 'auto', background: '#fff', borderRadius: 14, overflow: 'hidden', boxShadow: '0 24px 70px rgba(0,0,0,.34)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 20px', borderBottom: '1px solid var(--line)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<window.Emblem cfg={rc.emblem} size={34} />
|
||||
<span style={{ fontSize: 17, fontWeight: 800, color: 'var(--ink)' }}>{rc.name}</span>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--t3)', fontWeight: 600 }}>모집글 · {rc.createdAgo}분 전</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 30, height: 30, borderRadius: 6, border: 'none', background: '#eef0f2', color: '#5b626c', cursor: 'pointer', fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'stretch' }}>
|
||||
{/* 3:4 포스터 */}
|
||||
<div style={{ width: 360, flexShrink: 0, background: '#f4f5f7', borderRight: '1px solid var(--line)' }}>
|
||||
<image-slot id={`recruit-poster-${rc.name}`} shape="rect" placeholder="클랜 홍보 포스터 (3:4)를 끌어다 놓으세요" style={{ display: 'block', width: '100%', height: 480 }}></image-slot>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, padding: '20px 24px 24px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--line)', borderRadius: 7, overflow: 'hidden', marginBottom: 16 }}>
|
||||
{[['랭킹', `${rc.rank}위`, PZPT], ['승률', `${rc.weeklyWr}%`, 'var(--ink)'], ['전적', `${rc.record.matches}전 ${rc.record.w}승 ${rc.record.l}패`, 'var(--ink)']].map(([l, v, c], i) => (
|
||||
<div key={l} style={{ flex: i === 2 ? 1.6 : 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '11px 4px', borderLeft: i ? '1px solid var(--line)' : 'none', background: i % 2 ? '#fff' : '#fafbfc' }}>
|
||||
<div style={{ fontSize: 10, lineHeight: 1, color: 'var(--t3)', fontWeight: 600 }}>{l}</div>
|
||||
<div style={{ fontFamily: PZCH, fontWeight: 700, fontSize: 13.5, lineHeight: 1, whiteSpace: 'nowrap', color: c }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--t3)', marginBottom: 6 }}>모집 안내</div>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13.5, lineHeight: 1.7, color: '#3a4049', whiteSpace: 'pre-line' }}>{rc.intro}</p>
|
||||
|
||||
<div style={{ marginTop: 'auto', display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{rc.recruitTypes.map((t) => (
|
||||
<span key={t} style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', background: '#fff', padding: '3px 10px', borderRadius: 5 }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--t3)', marginBottom: 7 }}>가입 조건</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '7px 18px', marginBottom: 14, padding: '12px 14px', background: '#fafbfc', border: '1px solid var(--line)', borderRadius: 7 }}>
|
||||
{(() => {
|
||||
const cd = rc.conditions;
|
||||
return [
|
||||
['연령', pzAgeLabel(cd)],
|
||||
['보이스 채팅', cd.voice ? '필수' : '자율'],
|
||||
['닉네임 변경', cd.nickChange ? '필요' : '불필요'],
|
||||
['이중클랜·부계정', cd.noDualClan ? '금지' : '허용'],
|
||||
['지인 동반 가입', cd.friendJoin ? '가능' : '불가'],
|
||||
].map(([l, v]) => (
|
||||
<div key={l} style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--t3)', fontWeight: 600, flexShrink: 0 }}>{l}</span>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', textAlign: 'right' }}>{v}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<button onClick={() => onOpenClan(rc.name)} style={{ flex: 1, padding: '11px 0', borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>클랜 구경</button>
|
||||
<button style={{ flex: 1.3, padding: '11px 0', borderRadius: 7, border: 'none', background: PZPT, color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>가입 신청</button>
|
||||
{rc.contacts.map((ct) => {
|
||||
const isDc = ct.type === '디스코드';
|
||||
return (
|
||||
<button key={ct.type} title={ct.label} aria-label={ct.label} style={{ width: 42, height: 42, flexShrink: 0, display: 'grid', placeItems: 'center', borderRadius: 7, border: 'none', cursor: 'pointer', background: isDc ? '#5865F2' : '#FEE500' }}>
|
||||
<window.ContactGlyph type={ct.type} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 모집글 등록 모달 ──
|
||||
function RecruitCreate({ onClose }) {
|
||||
const SA = window.SA;
|
||||
const myClanName = SA.me.clan || '불곰부대';
|
||||
const clan = (window.ClanDB && window.ClanDB(myClanName)) || {};
|
||||
|
||||
const [types, setTypes] = React.useState([]);
|
||||
const [ageMin, setAgeMin] = React.useState('');
|
||||
const [ageMax, setAgeMax] = React.useState('');
|
||||
const [conds, setConds] = React.useState(['보이스 채팅 필수']);
|
||||
const [intro, setIntro] = React.useState('');
|
||||
const [dc, setDc] = React.useState(false);
|
||||
const [dcLink, setDcLink] = React.useState('');
|
||||
const [kakao, setKakao] = React.useState(false);
|
||||
const [kakaoLink, setKakaoLink] = React.useState('');
|
||||
|
||||
const toggleType = (v) => setTypes((t) => t.includes(v) ? t.filter((x) => x !== v) : [...t, v]);
|
||||
const toggleCond = (v) => setConds((t) => t.includes(v) ? t.filter((x) => x !== v) : [...t, v]);
|
||||
const label = { fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 };
|
||||
const chip = (on) => ({ padding: '6px 12px', borderRadius: 5, border: `1px solid ${on ? 'var(--ink)' : 'var(--line2)'}`, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? 'var(--ink)' : '#fff', color: on ? '#fff' : 'var(--t2)' });
|
||||
const valid = intro.trim().length > 0 && types.length > 0;
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250, background: 'rgba(20,22,26,.62)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 20px', overflowY: 'auto', animation: 'sgFade .2s ease' }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 760, maxWidth: '100%', margin: 'auto', background: '#fff', borderRadius: 14, overflow: 'hidden', boxShadow: '0 24px 70px rgba(0,0,0,.34)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '15px 20px', borderBottom: '1px solid var(--line)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16.5, fontWeight: 800, color: 'var(--ink)' }}>모집글 등록</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)', marginTop: 2 }}>랭킹 · 승률 · 전적은 클랜 데이터에서 자동으로 표시됩니다.</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 30, height: 30, borderRadius: 6, border: 'none', background: '#eef0f2', color: '#5b626c', cursor: 'pointer', fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'stretch' }}>
|
||||
{/* 포스터 업로드 */}
|
||||
<div style={{ width: 300, flexShrink: 0, background: '#f4f5f7', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column' }}>
|
||||
<image-slot id={`recruit-poster-${myClanName}`} shape="rect" placeholder="홍보 포스터 (3:4) 업로드" style={{ display: 'block', width: '100%', height: 400 }}></image-slot>
|
||||
<div style={{ padding: '11px 16px', color: 'var(--t3)', fontSize: 11, lineHeight: 1.5 }}>권장 비율 3:4 · 클랜을 가장 잘 보여줄 포스터를 올려보세요.</div>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<div style={{ flex: 1, minWidth: 0, padding: '18px 22px 20px', maxHeight: 520, overflowY: 'auto' }}>
|
||||
{/* 클랜 (자동) */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>클랜</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', border: '1px solid var(--line)', borderRadius: 7, background: '#fafbfc' }}>
|
||||
<window.Emblem cfg={clan.emblem} size={28} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)' }}>{myClanName}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--t3)', fontFamily: PZCH }}>{clan.rank}위</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모집 유형 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>모집 유형 <span style={{ color: 'var(--t3)', fontWeight: 600 }}>(어떤 유저를 찾나요?)</span></div>
|
||||
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
|
||||
{(window.RECRUIT_TYPES || []).map((v) => {
|
||||
const on = types.includes(v);
|
||||
return (
|
||||
<button key={v} onClick={() => toggleType(v)} style={chip(on)}>{v}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연령 조건 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>연령 조건 <span style={{ color: 'var(--t3)', fontWeight: 600 }}>(비워두면 전연령)</span></div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="number" min="1" max="99" value={ageMin} onChange={(e) => setAgeMin(e.target.value)} placeholder="제한 없음"
|
||||
style={{ width: 90, padding: '9px 11px', border: '1px solid var(--line2)', borderRadius: 7, fontSize: 13, fontFamily: "'Pretendard',sans-serif", fontWeight: 600, color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--t2)', flexShrink: 0 }}>세 이상</span>
|
||||
<span style={{ color: 'var(--line2)' }}>~</span>
|
||||
<input type="number" min="1" max="99" value={ageMax} onChange={(e) => setAgeMax(e.target.value)} placeholder="제한 없음"
|
||||
style={{ width: 90, padding: '9px 11px', border: '1px solid var(--line2)', borderRadius: 7, fontSize: 13, fontFamily: "'Pretendard',sans-serif", fontWeight: 600, color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--t2)', flexShrink: 0 }}>세 이하</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가입 조건 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>가입 조건 <span style={{ color: 'var(--t3)', fontWeight: 600 }}>(해당하는 항목 선택)</span></div>
|
||||
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
|
||||
{['보이스 채팅 필수', '닉네임 변경 필요', '이중클랜·부계 금지', '지인 동반 가입 가능'].map((v) => (
|
||||
<button key={v} onClick={() => toggleCond(v)} style={chip(conds.includes(v))}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모집 안내 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>모집 안내</div>
|
||||
<textarea value={intro} onChange={(e) => setIntro(e.target.value)} placeholder="클랜 분위기와 매너 기준, 활동 시간대, 클랜 목표(정기전·내전·티어 올리기 등)를 자유롭게 적어주세요." rows={5}
|
||||
style={{ width: '100%', resize: 'vertical', padding: '11px 12px', border: '1px solid var(--line2)', borderRadius: 7, fontSize: 13.5, lineHeight: 1.6, fontFamily: "'Pretendard',sans-serif", color: '#3a4049', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* 문의 채널 (선택) */}
|
||||
<div>
|
||||
<div style={label}>문의 채널 <span style={{ color: 'var(--t3)', fontWeight: 600 }}>(선택)</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
{[['디스코드', dc, () => setDc((v) => !v), dcLink, setDcLink, '#5865F2', 'https://discord.gg/...'], ['오픈카톡', kakao, () => setKakao((v) => !v), kakaoLink, setKakaoLink, '#FEE500', 'https://open.kakao.com/...']].map(([nm, on, fn, lk, setLk, bg, ph]) => (
|
||||
<div key={nm} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<button onClick={fn} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', minWidth: 124, borderRadius: 7, cursor: 'pointer', fontWeight: 700, fontSize: 13, fontFamily: "'Pretendard',sans-serif", flexShrink: 0,
|
||||
border: on ? `2px solid ${bg}` : '1px solid var(--line2)', background: on ? `${bg}14` : '#fff', color: on ? 'var(--ink)' : 'var(--t3)' }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: 5, background: bg, display: 'grid', placeItems: 'center' }}><window.ContactGlyph type={nm} /></span>
|
||||
{nm}
|
||||
</button>
|
||||
<input value={lk} onChange={(e) => setLk(e.target.value)} disabled={!on} placeholder={ph}
|
||||
style={{ flex: 1, minWidth: 0, padding: '9px 11px', border: '1px solid var(--line2)', borderRadius: 7, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", color: 'var(--ink)', background: on ? '#fff' : '#f4f5f7', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div style={{ display: 'flex', gap: 10, padding: '14px 20px', borderTop: '1px solid var(--line)', background: '#fafbfc' }}>
|
||||
<button onClick={onClose} style={{ flex: 1, padding: '12px 0', borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 14, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>취소</button>
|
||||
<button onClick={onClose} disabled={!valid} style={{ flex: 2, padding: '12px 0', borderRadius: 7, border: 'none', background: valid ? PZPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 14, cursor: valid ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif" }}>모집글 등록하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClanPlaza({ onClose, onOpenClan, onLogin, onClan, onCommunity }) {
|
||||
const all = window.ClanRecruits();
|
||||
const [rtype, setRtype] = React.useState('전체');
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
const [fAge, setFAge] = React.useState('');
|
||||
const [fVoice, setFVoice] = React.useState('전체');
|
||||
const [fNick, setFNick] = React.useState('전체');
|
||||
const [fDual, setFDual] = React.useState('전체');
|
||||
const [fFriend, setFFriend] = React.useState('전체');
|
||||
const [detail, setDetail] = React.useState(null);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const PER_PAGE = 8;
|
||||
const myClanName = window.SA.me.clan || '불곰부대';
|
||||
const myPost = all.find((c) => c.name === myClanName);
|
||||
const [myActive, setMyActive] = React.useState(() => { try { return localStorage.getItem('sa-my-recruit-active') !== '0'; } catch (e) { return true; } });
|
||||
const toggleMy = () => setMyActive((v) => { const n = !v; try { localStorage.setItem('sa-my-recruit-active', n ? '1' : '0'); } catch (e) {} return n; });
|
||||
|
||||
let list = [...all];
|
||||
if (rtype !== '전체') list = list.filter((c) => c.recruitTypes.includes(rtype));
|
||||
if (fAge) { const a = parseInt(fAge, 10); if (!isNaN(a)) list = list.filter((c) => (c.conditions.ageMin == null || a >= c.conditions.ageMin) && (c.conditions.ageMax == null || a <= c.conditions.ageMax)); }
|
||||
if (fVoice !== '전체') list = list.filter((c) => fVoice === '필수' ? c.conditions.voice : !c.conditions.voice);
|
||||
if (fNick !== '전체') list = list.filter((c) => fNick === '불필요' ? !c.conditions.nickChange : c.conditions.nickChange);
|
||||
if (fDual !== '전체') list = list.filter((c) => fDual === '허용' ? !c.conditions.noDualClan : c.conditions.noDualClan);
|
||||
if (fFriend !== '전체') list = list.filter((c) => fFriend === '가능' ? c.conditions.friendJoin : !c.conditions.friendJoin);
|
||||
if (!myActive) list = list.filter((c) => c.name !== myClanName);
|
||||
list = [...list].sort((a, b) => b.hot - a.hot);
|
||||
const totalPages = Math.max(1, Math.ceil(list.length / PER_PAGE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageList = list.slice((curPage - 1) * PER_PAGE, curPage * PER_PAGE);
|
||||
const goPage = (n) => { setPage(Math.max(1, Math.min(totalPages, n))); };
|
||||
const setRtypeReset = (v) => { setRtype(v); setPage(1); };
|
||||
const setF = (setter) => (v) => { setter(v); setPage(1); };
|
||||
const activeFilters = (fAge ? 1 : 0) + (fVoice !== '전체' ? 1 : 0) + (fNick !== '전체' ? 1 : 0) + (fDual !== '전체' ? 1 : 0) + (fFriend !== '전체' ? 1 : 0);
|
||||
const resetFilters = () => { setFAge(''); setFVoice('전체'); setFNick('전체'); setFDual('전체'); setFFriend('전체'); setPage(1); };
|
||||
const totalOpenings = all.reduce((s, c) => s + c.openings, 0);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg2)', display: 'flex', flexDirection: 'column', animation: 'sgFade .22s ease' }}>
|
||||
<window.Nav active="클랜" onLogin={onLogin} onClan={onClan} onCommunity={onCommunity} />
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 히어로 */}
|
||||
<div style={{ background: 'linear-gradient(135deg,#23262b,#2e333b)', color: '#fff' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '30px 28px 26px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: PZCH, fontSize: 11, fontWeight: 700, letterSpacing: '2.5px', color: 'rgba(255,255,255,.55)', marginBottom: 9 }}>CLAN RECRUIT PLAZA</div>
|
||||
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-.6px' }}>클랜 모집 광장</h1>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,.7)' }}>
|
||||
지금 <b style={{ color: '#fff', fontFamily: PZCH }}>{all.length}</b>개 클랜이 <b style={{ color: '#fff', fontFamily: PZCH }}>{totalOpenings}</b>명의 새 식구를 찾고 있어요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 검색 + 내 클랜 가기 */}
|
||||
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 240, maxWidth: 480 }}>
|
||||
<span style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', display: 'flex' }}><Icon name="search" size={17} stroke="rgba(255,255,255,.55)" /></span>
|
||||
<input placeholder="클랜명 · 분위기 · 포지션으로 검색" style={{ width: '100%', height: 42, paddingLeft: 40, paddingRight: 14, borderRadius: 8, border: '1px solid rgba(255,255,255,.2)', background: 'rgba(255,255,255,.1)', color: '#fff', fontSize: 14, outline: 'none', fontFamily: "'Pretendard',sans-serif" }} />
|
||||
</div>
|
||||
<button onClick={() => onOpenClan(window.SA.me.clan || '불곰부대')} style={{ height: 42, padding: '0 20px', borderRadius: 8, border: '1px solid rgba(255,255,255,.25)', background: 'rgba(255,255,255,.1)', color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", flexShrink: 0, marginLeft: 'auto' }}>
|
||||
내 클랜 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터/정렬 바 */}
|
||||
<div style={{ background: '#fff', borderBottom: '1px solid var(--line2)', position: 'sticky', top: 0, zIndex: 5 }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '12px 28px', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
|
||||
{['전체', ...(window.RECRUIT_TYPES || [])].map((v) => {
|
||||
const on = v === rtype;
|
||||
return (
|
||||
<button key={v} onClick={() => setRtypeReset(v)} style={{ padding: '6px 12px', borderRadius: 20, border: `1px solid ${on ? PZPT : 'var(--line2)'}`, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? PZPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{v}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--t3)', fontWeight: 600 }}>{list.length}개 클랜</span>
|
||||
<button onClick={() => setShowFilter((v) => !v)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px', borderRadius: 6, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5,
|
||||
border: `1px solid ${showFilter || activeFilters ? PZPT : 'var(--line2)'}`, background: showFilter ? PZPT : '#fff', color: showFilter ? '#fff' : (activeFilters ? PZPT : 'var(--ink)') }}>
|
||||
상세 필터{activeFilters > 0 && <span style={{ fontFamily: PZCH, fontSize: 11, fontWeight: 700, background: showFilter ? 'rgba(255,255,255,.25)' : `${PZPT}18`, borderRadius: 9, padding: '0 6px' }}>{activeFilters}</span>}
|
||||
</button>
|
||||
<button onClick={() => setCreating(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 6, border: 'none', background: PZPT, color: '#fff', fontWeight: 700, fontSize: 12.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
모집글 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 상세 필터 행 */}
|
||||
{showFilter && (
|
||||
<div style={{ borderTop: '1px solid var(--line)', background: '#fafbfc' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '12px 28px', display: 'flex', alignItems: 'flex-end', gap: 18, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', marginBottom: 6 }}>내 나이</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="number" min="1" max="99" value={fAge} onChange={(e) => setF(setFAge)(e.target.value)} placeholder="나이"
|
||||
style={{ width: 64, padding: '7px 9px', border: '1px solid var(--line2)', borderRadius: 6, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", fontWeight: 600, color: 'var(--ink)', outline: 'none', boxSizing: 'border-box', background: '#fff' }} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--t3)', fontWeight: 600 }}>세 가입 가능</span>
|
||||
</div>
|
||||
</div>
|
||||
{[['보이스 채팅', fVoice, setFVoice, ['전체', '필수', '자율']], ['닉네임 변경', fNick, setFNick, ['전체', '불필요', '필요']], ['이중클랜·부계', fDual, setFDual, ['전체', '허용', '금지']], ['지인 동반', fFriend, setFFriend, ['전체', '가능', '불가']]].map(([lab, val, setter, opts]) => (
|
||||
<div key={lab}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', marginBottom: 6 }}>{lab}</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{opts.map((o) => {
|
||||
const on = val === o;
|
||||
return (
|
||||
<button key={o} onClick={() => setF(setter)(o)} style={{ padding: '6px 11px', borderRadius: 6, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5,
|
||||
border: `1px solid ${on ? PZPT : 'var(--line2)'}`, background: on ? PZPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{o}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeFilters > 0 && (
|
||||
<button onClick={resetFilters} style={{ marginLeft: 'auto', padding: '7px 12px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--t2)', fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", textDecoration: 'underline' }}>초기화</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 내 모집글 관리 */}
|
||||
{myPost && (
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '16px 28px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, background: '#fff', border: `1px solid ${myActive ? 'var(--line)' : 'var(--line2)'}`, borderLeft: `3px solid ${myActive ? PZPT : '#c4c8ce'}`, borderRadius: 7, padding: '11px 16px', flexWrap: 'wrap' }}>
|
||||
<window.Emblem cfg={myPost.emblem} size={26} />
|
||||
<span style={{ fontSize: 13.5, fontWeight: 800, color: 'var(--ink)' }}>{myClanName}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: myActive ? PZPT : 'var(--t3)' }}>내 모집글 · {myActive ? '게시 중' : '비활성화됨'}</span>
|
||||
{!myActive && <span style={{ fontSize: 11.5, color: 'var(--t3)' }}>목록에 노출되지 않아요.</span>}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{myActive && <button onClick={() => setDetail(myPost)} style={{ padding: '7px 13px', borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>미리보기</button>}
|
||||
<button onClick={toggleMy} style={{ padding: '7px 13px', borderRadius: 6, border: 'none', background: myActive ? '#eef0f2' : PZPT, color: myActive ? '#5b626c' : '#fff', fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
{myActive ? '비활성화' : '다시 활성화'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '20px 28px 46px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 16 }}>
|
||||
{pageList.map((rc) => <RecruitCard key={rc.name} rc={rc} onOpen={onOpenClan} onDetail={setDetail} />)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 26 }}>
|
||||
<button onClick={() => goPage(curPage - 1)} disabled={curPage === 1} style={{ width: 32, height: 32, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: curPage === 1 ? 'default' : 'pointer', opacity: curPage === 1 ? .4 : 1, display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="chevL" size={15} stroke="var(--t2)" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button key={n} onClick={() => goPage(n)} style={{ minWidth: 32, height: 32, padding: '0 6px', borderRadius: 6, cursor: 'pointer', fontFamily: "'Chakra Petch',sans-serif", fontWeight: 700, fontSize: 13,
|
||||
border: n === curPage ? 'none' : '1px solid var(--line2)', background: n === curPage ? PZPT : '#fff', color: n === curPage ? '#fff' : 'var(--t2)' }}>{n}</button>
|
||||
))}
|
||||
<button onClick={() => goPage(curPage + 1)} disabled={curPage === totalPages} style={{ width: 32, height: 32, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: curPage === totalPages ? 'default' : 'pointer', opacity: curPage === totalPages ? .4 : 1, display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="chevR" size={15} stroke="var(--t2)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{detail && <RecruitDetail rc={detail} onClose={() => setDetail(null)} onOpenClan={onOpenClan} />}
|
||||
{creating && <RecruitCreate onClose={() => setCreating(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ClanPlaza = ClanPlaza;
|
||||
window.ContactGlyph = ContactGlyph;
|
||||
@@ -0,0 +1,370 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 클랜홈 섹션 컴포넌트 (브랜드 컬러 고정)
|
||||
// 의존: window.Panel, Icon, RankBadge, Emblem, SA
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const CLPT = '#48515f';
|
||||
const CLCH = "'Chakra Petch',sans-serif";
|
||||
|
||||
function RoleBadge({ role, theme }) {
|
||||
const map = {
|
||||
'클랜장': { bg: theme, color: '#fff' },
|
||||
'운영진': { bg: `${theme}22`, color: theme },
|
||||
'정예': { bg: '#eef0f2', color: '#5b626c' },
|
||||
'일반': { bg: 'transparent', color: '#949aa3' },
|
||||
};
|
||||
const s = map[role] || map['일반'];
|
||||
return <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: s.bg, color: s.color, flexShrink: 0 }}>{role}</span>;
|
||||
}
|
||||
|
||||
// ── 멤버 ──
|
||||
function ClanMembers({ clan, onPick }) {
|
||||
const groups = ['클랜장', '운영진', '정예', '일반'];
|
||||
const byRole = (r) => clan.members.filter((m) => m.role === r);
|
||||
return (
|
||||
<Panel title="클랜원" en="MEMBERS" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>{clan.totalMembers} / {clan.memberMax}명</span>} bodyPad="6px 0 6px">
|
||||
{groups.map((g) => {
|
||||
const list = byRole(g);
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<div key={g}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', padding: '8px 16px 4px' }}>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--t3)', letterSpacing: '.3px' }}>{g} · {list.length}</span>
|
||||
<span style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--t3)' }}>기여도</span>
|
||||
</div>
|
||||
{list.map((m, i) => (
|
||||
<button key={m.name + i} onClick={() => onPick && onPick(m.name)} style={{ display: 'flex', alignItems: 'center', gap: 9, width: '100%', border: 'none', background: 'transparent', padding: '8px 16px', cursor: 'pointer', textAlign: 'left', fontFamily: "'Pretendard',sans-serif" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f6f7f8'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
||||
{m.rank && <RankBadge rank={m.rank} size={26} />}
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.name}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 3, flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: CLCH, fontSize: 12.5, color: CLPT, fontWeight: 700 }}>{window.SA.fmt(m.contrib)}</span>
|
||||
<span style={{ fontSize: 9.5, color: 'var(--t3)', fontWeight: 600 }}>P</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 정기전 일정 ──
|
||||
function ClanSchedule({ clan }) {
|
||||
const t = CLPT;
|
||||
const [items, setItems] = React.useState(clan.schedule);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [day, setDay] = React.useState('월');
|
||||
const [time, setTime] = React.useState('21:00');
|
||||
const [title, setTitle] = React.useState('');
|
||||
React.useEffect(() => { setItems(clan.schedule); setOpen(false); }, [clan.name]);
|
||||
|
||||
const register = () => {
|
||||
const name = title.trim();
|
||||
if (!name) return;
|
||||
setItems(items.map((s) => s.day === day ? { ...s, on: true, time, title: name } : s));
|
||||
setTitle('');
|
||||
setOpen(false);
|
||||
};
|
||||
const remove = (d) => setItems(items.map((s) => s.day === d ? { ...s, on: false, time: '—', title: '휴식' } : s));
|
||||
|
||||
const inputStyle = { border: '1px solid var(--line2)', borderRadius: 6, padding: '9px 11px', fontSize: 13, fontFamily: "'Pretendard',sans-serif", fontWeight: 600, color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' };
|
||||
|
||||
return (
|
||||
<Panel title="클랜 일정" en="SCHEDULE" right={clan.isMine ? (
|
||||
<button onClick={() => setOpen(true)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: t, padding: 0 }}>+ 일정 등록</button>
|
||||
) : null} bodyPad="12px 14px 14px">
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
{items.map((s) => (
|
||||
<div key={s.day} style={{ flex: 1, textAlign: 'center', padding: '9px 2px', borderRadius: 6, background: s.on ? t : '#f4f5f7', color: s.on ? '#fff' : 'var(--t3)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800 }}>{s.day}</div>
|
||||
<div style={{ fontFamily: CLCH, fontSize: 9.5, marginTop: 2, opacity: .9 }}>{s.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.filter((s) => s.on).map((s) => (
|
||||
<div key={s.day} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
||||
<span style={{ fontWeight: 800, color: t, width: 16 }}>{s.day}</span>
|
||||
<span style={{ fontFamily: CLCH, color: 'var(--t2)', width: 44 }}>{s.time}</span>
|
||||
<span style={{ color: 'var(--ink)', fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.title}</span>
|
||||
{clan.isMine && (
|
||||
<button onClick={() => remove(s.day)} title="일정 삭제" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--t3)', fontSize: 14, lineHeight: 1, padding: '0 2px', flexShrink: 0 }}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.every((s) => !s.on) && <div style={{ fontSize: 12, color: 'var(--t3)', textAlign: 'center', padding: '6px 0' }}>등록된 일정이 없어요.</div>}
|
||||
</div>
|
||||
|
||||
{/* 일정 등록 모달 */}
|
||||
{open && (
|
||||
<div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 260, background: 'rgba(20,22,26,.55)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'sgFade .18s ease' }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 380, maxWidth: '100%', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 24px 70px rgba(0,0,0,.3)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 800, color: 'var(--ink)' }}>일정 등록</span>
|
||||
<button onClick={() => setOpen(false)} style={{ width: 28, height: 28, borderRadius: 6, border: 'none', background: '#eef0f2', color: '#5b626c', cursor: 'pointer', fontSize: 15 }}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 }}>요일</div>
|
||||
<div style={{ display: 'flex', gap: 5, marginBottom: 14 }}>
|
||||
{items.map((s) => (
|
||||
<button key={s.day} onClick={() => setDay(s.day)} style={{ flex: 1, padding: '8px 0', borderRadius: 6, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 800, fontSize: 12.5,
|
||||
border: `1px solid ${day === s.day ? t : 'var(--line2)'}`, background: day === s.day ? t : '#fff', color: day === s.day ? '#fff' : (s.on ? t : 'var(--t2)') }}>{s.day}</button>
|
||||
))}
|
||||
</div>
|
||||
{items.find((s) => s.day === day && s.on) && (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)', marginTop: -8, marginBottom: 12 }}>이미 일정이 있는 요일이에요. 등록하면 덮어써요.</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{ width: 120 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 }}>시간</div>
|
||||
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} style={{ ...inputStyle, width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 }}>일정명</div>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') register(); }} placeholder="예: 정기전, 내전 데이" style={{ ...inputStyle, width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setOpen(false)} style={{ flex: 1, padding: '11px 0', borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>취소</button>
|
||||
<button onClick={register} disabled={!title.trim()} style={{ flex: 1.4, padding: '11px 0', borderRadius: 7, border: 'none', background: title.trim() ? t : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: title.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif" }}>등록하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 주간 활동 (접속률) ──
|
||||
function ClanActivity({ clan }) {
|
||||
const days = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
const max = Math.max(...clan.attendance);
|
||||
return (
|
||||
<Panel title="주간 활동" en="ACTIVITY" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>접속률</span>} bodyPad="14px 14px 12px">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 7, height: 86 }}>
|
||||
{clan.attendance.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontFamily: CLCH, fontSize: 10, color: 'var(--t3)', fontWeight: 600 }}>{v}%</span>
|
||||
<div style={{ width: '100%', height: `${(v / max) * 58}px`, background: CLPT, borderRadius: '3px 3px 0 0', opacity: 0.55 + 0.45 * (v / max) }} />
|
||||
<span style={{ fontSize: 10.5, color: 'var(--t3)' }}>{days[i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 주간 매치 (모드별) ──
|
||||
function ClanWeeklyMatches({ clan }) {
|
||||
const days = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
const acts = clan.activity;
|
||||
const cats = [
|
||||
['clanWar', '클랜전', CLPT],
|
||||
['clanRanked', '클랜 랭크전', '#6b7585'],
|
||||
['soloRanked', '솔로 랭크전', '#99a1ae'],
|
||||
['partyRanked', '파티 랭크전', '#cdd2d9'],
|
||||
];
|
||||
const sum = (a) => cats.reduce((s, [k]) => s + a[k], 0);
|
||||
const max = Math.max(...acts.map(sum));
|
||||
const totals = cats.map(([k, label, color]) => [label, acts.reduce((s, a) => s + a[k], 0), color]);
|
||||
return (
|
||||
<Panel title="주간 매치" en="MATCHES" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>모드별 판수</span>} bodyPad="14px 14px 12px">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 7, height: 96 }}>
|
||||
{acts.map((a, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontFamily: CLCH, fontSize: 10, color: 'var(--t3)', fontWeight: 600 }}>{sum(a)}</span>
|
||||
<div style={{ width: '100%', height: `${(sum(a) / max) * 62}px`, display: 'flex', flexDirection: 'column-reverse', borderRadius: '3px 3px 0 0', overflow: 'hidden' }}>
|
||||
{cats.map(([k, label, color]) => (
|
||||
<div key={k} title={`${label} ${a[k]}판`} style={{ height: `${(a[k] / sum(a)) * 100}%`, background: color }} />
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--t3)' }}>{days[i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 11, paddingTop: 10, borderTop: '1px solid var(--line)', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '7px 10px' }}>
|
||||
{totals.map(([label, n, color]) => (
|
||||
<span key={label} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11.5, color: 'var(--t2)', fontWeight: 600 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: color, flexShrink: 0 }} />
|
||||
{label} <b style={{ fontFamily: CLCH, color: 'var(--ink)', marginLeft: 'auto' }}>{n}판</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 미니 페이저 ──
|
||||
function ClanPager({ page, total, onPage }) {
|
||||
if (total <= 1) return null;
|
||||
const nav = (dis) => ({ width: 26, height: 26, borderRadius: 5, border: '1px solid var(--line2)', background: '#fff', cursor: dis ? 'default' : 'pointer', opacity: dis ? .4 : 1, display: 'grid', placeItems: 'center' });
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '11px 0 5px' }}>
|
||||
<button onClick={() => onPage(page - 1)} disabled={page === 1} style={nav(page === 1)}><Icon name="chevL" size={13} stroke="var(--t2)" /></button>
|
||||
{Array.from({ length: total }, (_, i) => i + 1).map((n) => (
|
||||
<button key={n} onClick={() => onPage(n)} style={{ minWidth: 26, height: 26, padding: '0 5px', borderRadius: 5, cursor: 'pointer', fontFamily: CLCH, fontWeight: 700, fontSize: 11.5, border: n === page ? 'none' : '1px solid var(--line2)', background: n === page ? CLPT : '#fff', color: n === page ? '#fff' : 'var(--t2)' }}>{n}</button>
|
||||
))}
|
||||
<button onClick={() => onPage(page + 1)} disabled={page === total} style={nav(page === total)}><Icon name="chevR" size={13} stroke="var(--t2)" /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 공지 ──
|
||||
function ClanNotices({ clan }) {
|
||||
const PER = 4;
|
||||
const [page, setPage] = React.useState(1);
|
||||
React.useEffect(() => setPage(1), [clan.name]);
|
||||
const total = Math.max(1, Math.ceil(clan.notices.length / PER));
|
||||
const cur = Math.min(page, total);
|
||||
const rows = clan.notices.slice((cur - 1) * PER, cur * PER);
|
||||
return (
|
||||
<Panel title="클랜 공지" en="NOTICE" bodyPad="2px 14px 6px">
|
||||
{rows.map((n, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 2px', borderBottom: i < rows.length - 1 ? '1px solid #eaecef' : 'none', cursor: 'pointer' }}>
|
||||
{n.pin && <span style={{ fontSize: 10, fontWeight: 800, color: CLPT, flexShrink: 0 }}>고정</span>}
|
||||
<span style={{ flex: 1, fontSize: 13.5, color: '#33373d', fontWeight: n.pin ? 700 : 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)', flexShrink: 0 }}>{n.author} · {n.time}</span>
|
||||
</div>
|
||||
))}
|
||||
<ClanPager page={cur} total={total} onPage={(n) => setPage(Math.max(1, Math.min(total, n)))} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 클랜 피드 ──
|
||||
function ClanFeed({ clan }) {
|
||||
const PER = 3;
|
||||
const [items, setItems] = React.useState(clan.feed);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [page, setPage] = React.useState(1);
|
||||
React.useEffect(() => { setItems(clan.feed); setDraft(''); setPage(1); }, [clan.name]);
|
||||
const total = Math.max(1, Math.ceil(items.length / PER));
|
||||
const cur = Math.min(page, total);
|
||||
const rows = items.slice((cur - 1) * PER, cur * PER);
|
||||
const post = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setItems([{ author: window.SA.me.name, role: '일반', time: '방금 전', text, likes: 0 }, ...items]);
|
||||
setDraft('');
|
||||
setPage(1);
|
||||
};
|
||||
return (
|
||||
<Panel title="클랜 피드" en="FEED" bodyPad="6px 14px 12px">
|
||||
{clan.isMine && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '10px 0 12px', borderBottom: '1px solid var(--line)' }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: '50%', background: `${CLPT}1c`, color: CLPT, display: 'grid', placeItems: 'center', fontWeight: 800, fontSize: 14, flexShrink: 0 }}>{window.SA.me.name.slice(0, 1)}</div>
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') post(); }}
|
||||
placeholder="클랜원들에게 소식을 남겨보세요"
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '9px 12px', fontSize: 13, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
|
||||
<button onClick={post} disabled={!draft.trim()} style={{ padding: '0 16px', borderRadius: 6, border: 'none', cursor: draft.trim() ? 'pointer' : 'not-allowed', background: draft.trim() ? CLPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>작성</button>
|
||||
</div>
|
||||
)}
|
||||
{rows.map((f, i) => (
|
||||
<div key={(cur - 1) * PER + i} style={{ display: 'flex', gap: 11, padding: '11px 0', borderBottom: i < rows.length - 1 ? '1px solid #f1f2f4' : 'none' }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: '50%', background: `${CLPT}1c`, color: CLPT, display: 'grid', placeItems: 'center', fontWeight: 800, fontSize: 14, flexShrink: 0 }}>{f.author.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>{f.author}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--t3)' }}>{f.time}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: '#3a4049', lineHeight: 1.5 }}>{f.text}</div>
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 5, color: 'var(--t3)' }}>
|
||||
<Icon name="heart" size={13} stroke="var(--t3)" /><span style={{ fontSize: 11.5, fontWeight: 600 }}>{f.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ClanPager page={cur} total={total} onPage={(n) => setPage(Math.max(1, Math.min(total, n)))} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 방명록 ──
|
||||
function ClanGuestbook({ clan }) {
|
||||
const PER = 4;
|
||||
const [items, setItems] = React.useState(clan.guestbook);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [page, setPage] = React.useState(1);
|
||||
React.useEffect(() => { setItems(clan.guestbook); setDraft(''); setPage(1); }, [clan.name]);
|
||||
const total = Math.max(1, Math.ceil(items.length / PER));
|
||||
const cur = Math.min(page, total);
|
||||
const rows = items.slice((cur - 1) * PER, cur * PER);
|
||||
const post = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setItems([{ author: window.SA.me.name, text, time: '방금 전' }, ...items]);
|
||||
setDraft('');
|
||||
setPage(1);
|
||||
};
|
||||
const [replyAt, setReplyAt] = React.useState(-1);
|
||||
const [replyDraft, setReplyDraft] = React.useState('');
|
||||
React.useEffect(() => { setReplyAt(-1); setReplyDraft(''); }, [clan.name]);
|
||||
const postReply = (gi) => {
|
||||
const text = replyDraft.trim();
|
||||
if (!text) return;
|
||||
setItems(items.map((g, idx) => idx === gi ? { ...g, replies: [...(g.replies || []), { author: window.SA.me.name, text, time: '방금 전' }] } : g));
|
||||
setReplyDraft('');
|
||||
setReplyAt(-1);
|
||||
};
|
||||
return (
|
||||
<Panel title="방명록" en="GUESTBOOK" right={clan.isMine ? <span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>클랜원은 답글만 달 수 있어요</span> : null} bodyPad="10px 14px 14px">
|
||||
{!clan.isMine && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') post(); }} placeholder="따뜻한 한마디를 남겨보세요" style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '9px 12px', fontSize: 13, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
|
||||
<button onClick={post} disabled={!draft.trim()} style={{ padding: '0 16px', borderRadius: 6, border: 'none', background: draft.trim() ? CLPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 13, cursor: draft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
|
||||
</div>
|
||||
)}
|
||||
{rows.map((g, i) => {
|
||||
const gi = (cur - 1) * PER + i;
|
||||
const replies = g.replies || [];
|
||||
return (
|
||||
<div key={gi} style={{ display: 'flex', gap: 9, padding: '9px 0', borderTop: '1px solid #f1f2f4' }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#eef0f2', color: '#7a8089', display: 'grid', placeItems: 'center', fontWeight: 700, fontSize: 12, flexShrink: 0 }}>{g.author.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }}>{g.author}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{g.time}</span>
|
||||
{clan.isMine && (
|
||||
<button onClick={() => { setReplyAt(replyAt === gi ? -1 : gi); setReplyDraft(''); }} style={{ marginLeft: 'auto', border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11, color: CLPT, padding: 0, flexShrink: 0 }}>
|
||||
{replyAt === gi ? '취소' : '답글'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 2 }}>{g.text}</div>
|
||||
|
||||
{/* 답글 목록 */}
|
||||
{replies.map((rp, j) => (
|
||||
<div key={j} style={{ display: 'flex', gap: 9, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CLPT}33` }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: `${CLPT}1c`, color: CLPT, display: 'grid', placeItems: 'center', fontWeight: 800, fontSize: 12, flexShrink: 0 }}>{rp.author.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }}>{rp.author}</span>
|
||||
<span style={{ fontSize: 9.5, fontWeight: 700, color: CLPT, border: `1px solid ${CLPT}55`, borderRadius: 3, padding: '0 4px' }}>클랜원</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{rp.time}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 4 }}>{rp.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 답글 입력 */}
|
||||
{replyAt === gi && (
|
||||
<div style={{ display: 'flex', gap: 7, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CLPT}33` }}>
|
||||
<input autoFocus value={replyDraft} onChange={(e) => setReplyDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') postReply(gi); }} placeholder="답글을 남겨보세요"
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '7px 10px', fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
|
||||
<button onClick={() => postReply(gi)} disabled={!replyDraft.trim()} style={{ padding: '0 13px', borderRadius: 6, border: 'none', background: replyDraft.trim() ? CLPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 11.5, cursor: replyDraft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ClanPager page={cur} total={total} onPage={(n) => setPage(Math.max(1, Math.min(total, n)))} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ClanMembers, ClanSchedule, ClanActivity, ClanWeeklyMatches, ClanNotices, ClanFeed, ClanGuestbook });
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 클랜홈 메인 화면 (브랜드 컬러 고정)
|
||||
// window.ClanHome · 의존: Nav, Panel, Icon, Emblem, ClanDB, 섹션들(window)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function ClanHome({ clanName, onClose, onPick, onLogin, onCommunity }) {
|
||||
const clan = window.ClanDB(clanName);
|
||||
const t = '#48515f'; // 브랜드 포인트 컬러(올리브)
|
||||
|
||||
const stat = (label, value, sub) => (
|
||||
<div style={{ flex: 1, textAlign: 'center', padding: '12px 6px', display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 60 }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ fontFamily: "'Chakra Petch',sans-serif", fontWeight: 700, fontSize: 15, color: 'var(--ink)', lineHeight: 1.1 }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 10, color: t, fontWeight: 700, fontFamily: "'Chakra Petch',sans-serif", marginTop: 3 }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg2)', display: 'flex', flexDirection: 'column', animation: 'sgFade .22s ease' }}>
|
||||
<window.Nav active="클랜" onLogin={onLogin} onCommunity={onCommunity} />
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 히어로 배너 */}
|
||||
<div style={{ position: 'relative', background: '#1c1f24' }}>
|
||||
<image-slot id={`clan-banner-${clan.name}`} shape="rect" placeholder="클랜 대표 배너 이미지를 끌어다 놓으세요" style={{ display: 'block', width: '100%', height: 210, color: 'rgba(255,255,255,.78)' }}></image-slot>
|
||||
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${t}26 0%, rgba(20,22,26,.34) 46%, rgba(20,22,26,.9) 100%)`, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: 14, left: 0, right: 0, maxWidth: 1180, margin: '0 auto', padding: '0 28px', display: 'flex', justifyContent: 'flex-end', pointerEvents: 'none' }}>
|
||||
<button onClick={onClose} style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 5, padding: '7px 13px', borderRadius: 6, border: 'none', background: 'rgba(0,0,0,.42)', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<Icon name="chevL" size={15} stroke="#fff" /> 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 엠블럼 + 정체성 */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, maxWidth: 1180, margin: '0 auto', padding: '0 28px 20px', display: 'flex', alignItems: 'flex-end', gap: 20 }}>
|
||||
<div style={{ flexShrink: 0 }}><window.Emblem cfg={clan.emblem} size={104} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, color: '#fff', letterSpacing: '-.6px', textShadow: '0 2px 12px rgba(0,0,0,.4)' }}>{clan.name}</h1>
|
||||
{clan.isMine && <span style={{ padding: '3px 10px', borderRadius: 5, background: t, color: '#fff', fontWeight: 700, fontSize: 11.5 }}>내 클랜</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, fontSize: 14.5, color: 'rgba(255,255,255,.9)', fontWeight: 500, fontStyle: 'italic' }}>“{clan.slogan}”</div>
|
||||
</div>
|
||||
{!clan.isMine && (
|
||||
<div style={{ flexShrink: 0, paddingBottom: 4 }}>
|
||||
<button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '10px 20px', borderRadius: 7, border: 'none', cursor: 'pointer', background: t, color: '#fff', fontWeight: 700, fontSize: 13.5, fontFamily: "'Pretendard',sans-serif", boxShadow: '0 3px 12px rgba(0,0,0,.25)' }}>
|
||||
가입 신청
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스탯 스트립 */}
|
||||
<div style={{ background: '#fff', borderBottom: '1px solid var(--line2)' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '0 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
{stat('클랜 랭킹', `${clan.rank}위`)}
|
||||
<span style={{ width: 1, background: 'var(--line)' }} />
|
||||
{stat('클랜원', `${clan.totalMembers}명`)}
|
||||
<span style={{ width: 1, background: 'var(--line)' }} />
|
||||
{stat('창설일', clan.founded)}
|
||||
<span style={{ width: 1, background: 'var(--line)' }} />
|
||||
{stat('클랜 경험치', `${window.SA.fmt(clan.expTotal)} EXP`, `▲ ${window.SA.fmt(clan.expWeek)} 이번 주`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '22px 28px 44px' }}>
|
||||
{/* 소개 */}
|
||||
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 6, padding: '16px 18px', marginBottom: 18, borderLeft: `4px solid ${t}` }}>
|
||||
<div style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--t3)', marginBottom: 7 }}>클랜 소개</div>
|
||||
<p style={{ margin: 0, fontSize: 13.5, lineHeight: 1.65, color: '#3a4049' }}>{clan.intro}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 322px', gap: 18, alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, minWidth: 0 }}>
|
||||
<window.ClanNotices clan={clan} />
|
||||
<window.ClanFeed clan={clan} />
|
||||
<window.ClanGuestbook clan={clan} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<window.ClanSchedule clan={clan} />
|
||||
<window.ClanActivity clan={clan} />
|
||||
<window.ClanWeeklyMatches clan={clan} />
|
||||
<window.ClanMembers clan={clan} onPick={onPick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ClanHome = ClanHome;
|
||||
@@ -0,0 +1,551 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 커뮤니티 (목록 화면)
|
||||
// 게시판: 자유 / 공략·팁 / 질문 / 파티 모집 — 텍스트 리스트형
|
||||
// 사이드: 인기글(HOT) · 주간 베스트
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const CMPT = '#48515f';
|
||||
const CMCH = "'Chakra Petch',sans-serif";
|
||||
|
||||
// ── 게시글 더미 데이터 (시드 고정) ──
|
||||
const CM_BOARDS = ['자유', '공략·팁', '질문', '파티 모집'];
|
||||
const CM_POSTS = (() => {
|
||||
const titles = {
|
||||
'자유': ['오늘 제3보급 진짜 미친 매치 했다', '시즌2 보상 다들 받았음?', '요즘 저녁 큐 잡히는 속도 어떰', '복귀했는데 감도 설정 다 까먹음', '클랜전 하다가 현타온 썰', '이번 패치 평가 어떻게 생각함', '10년차 유저인데 아직도 재밌네', '오랜만에 5연승 함 ㅋㅋ', '새벽 매치 분위기 좋아서 글 남김', '내일 점검이라는데 뭐 바뀜?', '신캐 나오면 누구 뽑을 거임', '오늘자 명장면.gif 올림'],
|
||||
'공략·팁': ['데드폴 수비 루트 총정리 (시즌2 기준)', '웨어하우스 러시 타이밍 분석', '저감도 vs 고감도 — 결론 정리해줌', '라이플 반동 제어 연습법 공유', '컨테이너시티 스나 포지션 5곳', '클랜 랭크전 픽 우선순위 가이드', '초보가 제일 많이 하는 실수 7가지', '3vs3 맵별 스폰 위치 정리', '에임 워밍업 루틴 추천', '파티 랭크전 콜 사인 정리본'],
|
||||
'질문': ['랭크 점수 산정 방식 아시는 분?', '티어 강등 기준이 어떻게 되나요', '클랜 옮기면 클랜전 기록 초기화되나요?', '모니터 144hz 체감 큰가요', '복귀 유저인데 뭐부터 하면 됨?', '계급이랑 티어 차이가 뭔가요', '신고하면 처리 결과 알 수 있나요', '파티 랭크전 듀오 가능한가요', '토너먼트 참가 조건 아시는 분', '전적 갱신이 안 되는데 저만 그런가요'],
|
||||
'파티 모집': ['파티 랭크전 같이 하실 분 (골드 이상)', '저녁 9시 클랜전 용병 1명 구해요', '3보급 내전 인원 모집합니다', '주말 토너먼트 팀원 구함 (스나 1)', '심야 듀오 구합니다 — 마스터 부근', '즐겜 파티 상시 모집 중', '음성 가능한 파티 멤버 2명', 'A보급 연습 같이 하실 분', '평일 오후 파티 고정 멤버 구해요', '랭커 도전 듀오 모집 (진지하게)'],
|
||||
};
|
||||
const nicks = ['독수리오형제', '한밤의저격수', '돌격대장', '연사의신', '클러치킹', '광속에임', '벽뚫는소리', '플래시뱅', '노스코프', '리코일제로', '소음기장착', '검은표범', '강철멘탈', '야시경', '엄폐엄폐', '침착한손', '스나는예술', '원피스원킬'];
|
||||
const out = [];
|
||||
let id = 0;
|
||||
CM_BOARDS.forEach((board, bi) => {
|
||||
titles[board].forEach((title, ti) => {
|
||||
const seed = (bi * 131 + ti * 17 + title.length * 7) % 9973;
|
||||
const hours = 1 + (seed % 70);
|
||||
out.push({
|
||||
id: ++id, board, title,
|
||||
author: nicks[(seed + bi) % nicks.length],
|
||||
time: hours < 24 ? `${hours}시간 전` : `${Math.floor(hours / 24)}일 전`,
|
||||
hoursAgo: hours,
|
||||
views: 120 + (seed % 4200),
|
||||
cmt: seed % 46,
|
||||
up: seed % 88,
|
||||
hot: seed % 5 === 0,
|
||||
hasImage: seed % 3 === 0,
|
||||
party: board === '파티 모집' ? {
|
||||
ptype: ['클랜전', '파티 랭크전', '클랜 랭크전', '토너먼트', '생존', '기타'][seed % 6],
|
||||
voice: seed % 3 !== 0,
|
||||
} : null,
|
||||
});
|
||||
});
|
||||
});
|
||||
return out.sort((a, b) => a.hoursAgo - b.hoursAgo);
|
||||
})();
|
||||
|
||||
// ── 고정 공지 ──
|
||||
const CM_NOTICES = [
|
||||
{ title: '커뮤니티 이용 규칙 안내 — 비방·도배·정치 글 제재', time: '운영팀' },
|
||||
{ title: '파티 모집 글은 파티 모집 게시판에만 작성해주세요', time: '운영팀' },
|
||||
];
|
||||
|
||||
// 파티 모집 유형
|
||||
const CM_PARTY_TYPES = ['클랜전', '파티 랭크전', '클랜 랭크전', '토너먼트', '생존', '기타'];
|
||||
// 파티 모집 조건 → 배지 라벨 배열
|
||||
function cmPartyBadges(party) {
|
||||
return [party.ptype, party.voice ? '보이스 채팅 필수' : null].filter(Boolean);
|
||||
}
|
||||
|
||||
// 개념글 기준 추천 수
|
||||
const CM_BEST_UP = 40;
|
||||
|
||||
// 서든어택 계정 인증 여부 (닉네임 시드 고정)
|
||||
function cmVerified(name) {
|
||||
if (!name) return false;
|
||||
if (window.SA && window.SA.me && name === window.SA.me.name) return true;
|
||||
let s = 0;
|
||||
for (let i = 0; i < name.length; i++) s = (s + name.charCodeAt(i) * (i + 1)) % 997;
|
||||
return s % 3 !== 0; // 약 2/3 인증
|
||||
}
|
||||
// 인증 배지 (인증=서든 마크 / 미인증=표시 없음)
|
||||
function CmVerifiedTag({ name, size = 13 }) {
|
||||
if (!cmVerified(name)) return null;
|
||||
return (
|
||||
<span title="서든어택 계정 인증 유저" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: size, height: size, borderRadius: '50%', background: CMPT, flexShrink: 0 }}>
|
||||
<svg width={size * 0.68} height={size * 0.68} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
function CmAuthor({ name, style }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
||||
<span style={{ ...style, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
|
||||
<CmVerifiedTag name={name} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 글 상세 더미 (본문·댓글, 시드 고정) ──
|
||||
const CM_BODIES = {
|
||||
'자유': '오늘 매치 돌리면서 느낀 점 적어봅니다.\n\n요즘 큐 잡히는 속도도 그렇고 전반적인 매치 퀄리티가 시즌 초반보다 확실히 좋아진 느낌이에요. 다들 체감 어떠신지 궁금합니다.\n\n댓글로 의견 남겨주세요!', '공략·팁': '직접 테스트해보고 정리한 내용입니다.\n\n1. 진입 타이밍은 소리 정보가 제일 중요합니다. 발소리 출력을 충분히 키우세요.\n2. 교전 전에 미니맵으로 아군 위치를 확인하는 습관을 들이면 생존율이 크게 올라갑니다.\n3. 마지막으로, 장시간 연승보다 짧게 끊어서 집중하는 게 에임 유지에 좋았습니다.\n\n궁금한 점은 댓글로 물어보세요.', '질문': '검색해봐도 정확한 내용이 안 나와서 질문 남깁니다.\n\n아시는 분 계시면 답변 부탁드려요. 미리 감사합니다!', '파티 모집': '안녕하세요, 글 제목 그대로 함께하실 분 찾습니다.\n\n· 매너 좋으신 분이면 실력 무관\n· 저녁 시간대 위주로 활동합니다\n\n댓글이나 쪽지 주세요!',
|
||||
};
|
||||
const CM_CMT_TEXTS = ['완전 공감합니다 ㅋㅋ', '좋은 글 감사합니다. 추천 누르고 갑니다', '이거 진짜 유용하네요. 저장해둡니다', '저는 생각이 좀 다른데... 그래도 잘 봤습니다', '댓글 보고 바로 해봤는데 효과 있음', '이런 글 많이 올라오면 좋겠어요', '쪽지 보냈습니다!', '오 나도 어제 비슷한 경험 함', '정리 깔낁하네요 잘 읽었습니다', '이거 때문에 요즘 매치 분위기 좋아짐'];
|
||||
const CM_CMT_NICKS = ['안개손절이', '플래그팔로우', '리스폰지옥', '점수지키미', '안전제일', '한발더', '에임수엽', '무릎샇소리'];
|
||||
function cmDetail(p) {
|
||||
const seed = (p.id * 37) % 9973;
|
||||
const n = 2 + (seed % 5);
|
||||
const comments = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
comments.push({
|
||||
author: CM_CMT_NICKS[(seed + i * 3) % CM_CMT_NICKS.length],
|
||||
text: CM_CMT_TEXTS[(seed + i * 5) % CM_CMT_TEXTS.length],
|
||||
time: `${1 + ((seed + i * 7) % 20)}시간 전`,
|
||||
up: (seed + i * 11) % 18,
|
||||
replies: i === 0 ? [{ author: CM_CMT_NICKS[(seed + 5) % CM_CMT_NICKS.length], text: '맞아요, 저도 비슷하게 느꼈습니다.', time: `${1 + (seed % 12)}시간 전` }] : [],
|
||||
});
|
||||
}
|
||||
return { body: p.body || CM_BODIES[p.board], comments };
|
||||
}
|
||||
|
||||
// ── 목록 행 ──
|
||||
function CmRow({ p, zebra, onOpen }) {
|
||||
return (
|
||||
<div onClick={() => onOpen && onOpen(p)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', background: zebra ? '#fbfbfc' : '#fff', borderBottom: '1px solid #eef0f2', cursor: 'pointer' }}>
|
||||
<span style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: '#f1f3f5', border: '1px solid var(--line)', color: '#5a5f66', minWidth: 44, textAlign: 'center' }}>{p.board}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ minWidth: 0, flexShrink: 1, fontSize: 13.5, lineHeight: 1.3, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</span>
|
||||
{p.hasImage && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--t3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} title="사진 포함">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
{p.cmt > 0 && <span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11.5, lineHeight: 1, fontWeight: 700, color: CMPT }}>[{p.cmt}]</span>}
|
||||
{p.hot && <span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 9.5, lineHeight: 1, fontWeight: 800, color: '#b23b3b' }}>HOT</span>}
|
||||
{p.party && (
|
||||
<span style={{ flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
{cmPartyBadges(p.party).slice(0, 3).map((b) => (
|
||||
<span key={b} style={{ display: 'inline-flex', alignItems: 'center', height: 18, fontSize: 9.5, fontWeight: 700, lineHeight: 1, color: 'var(--t2)', border: '1px solid var(--line2)', borderRadius: 3, padding: '0 5px', boxSizing: 'border-box' }}>{b}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11, width: 52, textAlign: 'right', color: p.up >= CM_BEST_UP ? CMPT : 'var(--t3)', fontWeight: p.up >= CM_BEST_UP ? 700 : 400 }}>추천 {p.up}</span>
|
||||
<span style={{ flexShrink: 0, width: 92, display: 'flex', justifyContent: 'flex-end' }}><CmAuthor name={p.author} style={{ fontSize: 12, color: 'var(--t2)' }} /></span>
|
||||
<span style={{ flexShrink: 0, fontSize: 11, color: 'var(--t3)', width: 56, textAlign: 'right' }}>{p.time}</span>
|
||||
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11, color: 'var(--t3)', width: 52, textAlign: 'right' }}>{window.SA.fmt(p.views)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 사이드 위젯 ──
|
||||
function CmSidePanel({ title, en, posts, valueOf, onOpen }) {
|
||||
return (
|
||||
<Panel title={title} en={en} bodyPad="4px 0 6px">
|
||||
{posts.map((p, i) => (
|
||||
<div key={p.id} onClick={() => onOpen && onOpen(p)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 14px', borderBottom: i < posts.length - 1 ? '1px solid #f1f2f4' : 'none', cursor: 'pointer' }}>
|
||||
<span style={{ width: 14, textAlign: 'center', fontFamily: CMCH, fontWeight: 700, fontSize: 12.5, lineHeight: 1, color: i < 3 ? CMPT : 'var(--t3)', flexShrink: 0 }}>{i + 1}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, lineHeight: 1.3, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</span>
|
||||
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 10.5, lineHeight: 1, color: 'var(--t3)', whiteSpace: 'nowrap' }}>{valueOf(p)}</span>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 글 상세 ──
|
||||
function CmPostDetail({ p, onBack }) {
|
||||
const d = React.useMemo(() => cmDetail(p), [p.id]);
|
||||
const [comments, setComments] = React.useState(d.comments);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [up, setUp] = React.useState(p.up);
|
||||
const [voted, setVoted] = React.useState(0); // 0 없음 / 1 추천 / -1 비추
|
||||
React.useEffect(() => { setComments(d.comments); setDraft(''); setUp(p.up); setVoted(0); setCmtVoted({}); }, [p.id]);
|
||||
const vote = (v) => {
|
||||
if (voted === v) { setVoted(0); setUp(up - v); return; }
|
||||
setUp(up - voted + v);
|
||||
setVoted(v);
|
||||
};
|
||||
const delCmt = (i) => setComments(comments.map((c, idx) => idx === i ? { ...c, deleted: true } : c));
|
||||
const post = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setComments([...comments, { author: window.SA.me.name, text, time: '방금 전', up: 0, replies: [] }]);
|
||||
setDraft('');
|
||||
};
|
||||
const [replyAt, setReplyAt] = React.useState(-1);
|
||||
const [replyDraft, setReplyDraft] = React.useState('');
|
||||
const [cmtVoted, setCmtVoted] = React.useState({});
|
||||
const voteCmt = (i) => setCmtVoted((v) => ({ ...v, [i]: !v[i] }));
|
||||
const postReply = (ci) => {
|
||||
const text = replyDraft.trim();
|
||||
if (!text) return;
|
||||
setComments(comments.map((c, idx) => idx === ci ? { ...c, replies: [...(c.replies || []), { author: window.SA.me.name, text, time: '방금 전' }] } : c));
|
||||
setReplyDraft('');
|
||||
setReplyAt(-1);
|
||||
};
|
||||
return (
|
||||
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
{/* 상단 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--line)' }}>
|
||||
<button onClick={onBack} style={{ display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--line2)', background: '#fff', borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)' }}>
|
||||
<Icon name="chevL" size={13} stroke="var(--t2)" /> 목록
|
||||
</button>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: '#f1f3f5', border: '1px solid var(--line)', color: '#5a5f66' }}>{p.board}</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 + 메타 */}
|
||||
<div style={{ padding: '16px 18px 14px', borderBottom: '1px solid var(--line)' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 19, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.3px', lineHeight: 1.4 }}>{p.title}</h2>
|
||||
<div style={{ marginTop: 9, display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--t3)', flexWrap: 'wrap' }}>
|
||||
<CmAuthor name={p.author} style={{ fontWeight: 700, color: 'var(--t2)' }} />
|
||||
<span>·</span><span>{p.time}</span>
|
||||
<span>·</span><span>조회 <b style={{ fontFamily: CMCH, color: 'var(--t2)' }}>{window.SA.fmt(p.views)}</b></span>
|
||||
<span>·</span><span>추천 <b style={{ fontFamily: CMCH, color: up >= CM_BEST_UP ? CMPT : 'var(--t2)' }}>{up}</b></span>
|
||||
</div>
|
||||
{p.party && (
|
||||
<div style={{ marginTop: 11, display: 'flex', gap: 6 }}>
|
||||
{cmPartyBadges(p.party).map((b) => (
|
||||
<span key={b} style={{ display: 'inline-flex', alignItems: 'center', height: 22, fontSize: 11, fontWeight: 700, color: 'var(--t2)', border: '1px solid var(--line2)', borderRadius: 4, padding: '0 8px' }}>{b}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div style={{ padding: '18px 18px 8px', fontSize: 14, lineHeight: 1.8, color: '#33373d', whiteSpace: 'pre-line' }}>{d.body}</div>
|
||||
|
||||
{/* 추천/비추 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '16px 0 20px' }}>
|
||||
<button onClick={() => vote(1)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px', borderRadius: 7, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 13,
|
||||
border: `1px solid ${voted === 1 ? CMPT : 'var(--line2)'}`, background: voted === 1 ? CMPT : '#fff', color: voted === 1 ? '#fff' : 'var(--t2)' }}>
|
||||
▲ 추천 <span style={{ fontFamily: CMCH }}>{up}</span>
|
||||
</button>
|
||||
<button onClick={() => vote(-1)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px', borderRadius: 7, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 13,
|
||||
border: `1px solid ${voted === -1 ? '#b23b3b' : 'var(--line2)'}`, background: voted === -1 ? '#b23b3b' : '#fff', color: voted === -1 ? '#fff' : 'var(--t2)' }}>
|
||||
▼ 비추
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 댓글 */}
|
||||
<div style={{ borderTop: '1px solid var(--line)', padding: '14px 18px 18px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: 'var(--ink)', marginBottom: 10 }}>댓글 <span style={{ fontFamily: CMCH, color: CMPT }}>{comments.length}</span></div>
|
||||
{comments.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 9, padding: '10px 0', borderTop: i ? '1px solid #f1f2f4' : 'none' }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#eef0f2', color: '#7a8089', display: 'grid', placeItems: 'center', fontWeight: 700, fontSize: 12, flexShrink: 0, opacity: c.deleted ? .5 : 1 }}>{c.deleted ? '–' : c.author.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{c.deleted ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--t3)', fontStyle: 'italic', padding: '5px 0' }}>삭제된 댓글입니다.</div>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<CmAuthor name={c.author} style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{c.time}</span>
|
||||
<button onClick={() => { setReplyAt(replyAt === i ? -1 : i); setReplyDraft(''); }} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11, color: CMPT, padding: 0 }}>
|
||||
{replyAt === i ? '취소' : '답글'}
|
||||
</button>
|
||||
{c.author === window.SA.me.name && (
|
||||
<button onClick={() => delCmt(i)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11, color: '#b23b3b', padding: 0 }}>삭제</button>
|
||||
)}
|
||||
<button onClick={() => voteCmt(i)} title="댓글 추천" style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 4, border: `1px solid ${cmtVoted[i] ? CMPT : 'var(--line2)'}`, background: cmtVoted[i] ? CMPT : '#fff', color: cmtVoted[i] ? '#fff' : 'var(--t3)', borderRadius: 5, padding: '3px 8px', cursor: 'pointer', fontFamily: CMCH, fontSize: 10.5, fontWeight: 700 }}>
|
||||
▲ {c.up + (cmtVoted[i] ? 1 : 0)}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 3, lineHeight: 1.55 }}>{c.text}</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{/* 답글 목록 */}
|
||||
{(c.replies || []).map((rp, j) => (
|
||||
<div key={j} style={{ display: 'flex', gap: 9, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CMPT}33` }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: `${CMPT}1c`, color: CMPT, display: 'grid', placeItems: 'center', fontWeight: 800, fontSize: 12, flexShrink: 0 }}>{rp.author.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<CmAuthor name={rp.author} style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{rp.time}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 3, lineHeight: 1.55 }}>{rp.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 답글 입력 */}
|
||||
{replyAt === i && (
|
||||
<div style={{ display: 'flex', gap: 7, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CMPT}33` }}>
|
||||
<input autoFocus value={replyDraft} onChange={(e) => setReplyDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') postReply(i); }} placeholder="답글을 남겨보세요"
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '7px 10px', fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
|
||||
<button onClick={() => postReply(i)} disabled={!replyDraft.trim()} style={{ padding: '0 13px', borderRadius: 6, border: 'none', background: replyDraft.trim() ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 11.5, cursor: replyDraft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') post(); }} placeholder="댓글을 남겨보세요"
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '10px 12px', fontSize: 13, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
|
||||
<button onClick={post} disabled={!draft.trim()} style={{ padding: '0 18px', borderRadius: 6, border: 'none', background: draft.trim() ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 13, cursor: draft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 글쓰기 화면 (일반 / 파티 모집) ──
|
||||
function CmWrite({ initialBoard, onCancel, onSubmit }) {
|
||||
const [board, setBoard] = React.useState(initialBoard && initialBoard !== '전체' ? initialBoard : '자유');
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [body, setBody] = React.useState('');
|
||||
const [hasImage, setHasImage] = React.useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
// 파티 모집 전용
|
||||
const [ptype, setPtype] = React.useState(CM_PARTY_TYPES[0]);
|
||||
const [voice, setVoice] = React.useState(true);
|
||||
|
||||
const isParty = board === '파티 모집';
|
||||
const valid = title.trim() && body.trim();
|
||||
const label = { fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 };
|
||||
const chip = (on) => ({ padding: '6px 12px', borderRadius: 5, border: `1px solid ${on ? 'var(--ink)' : 'var(--line2)'}`, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? 'var(--ink)' : '#fff', color: on ? '#fff' : 'var(--t2)' });
|
||||
const inputBase = { width: '100%', border: '1px solid var(--line2)', borderRadius: 7, padding: '10px 12px', fontSize: 13.5, fontFamily: "'Pretendard',sans-serif", color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' };
|
||||
|
||||
const submit = () => {
|
||||
if (!valid) return;
|
||||
onSubmit({
|
||||
board, title: title.trim(),
|
||||
author: window.SA.me.name, time: '방금 전', hoursAgo: 0,
|
||||
views: 1, cmt: 0, up: 0, hot: false, hasImage,
|
||||
body: body.trim(),
|
||||
party: isParty ? { ptype, voice } : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 16px', borderBottom: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 15.5, fontWeight: 800, color: 'var(--ink)' }}>글쓰기</span>
|
||||
<button onClick={onCancel} style={{ border: '1px solid var(--line2)', background: '#fff', borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)' }}>목록</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '18px 18px 20px' }}>
|
||||
{/* 게시판 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>게시판</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{CM_BOARDS.map((b) => {
|
||||
const on = board === b;
|
||||
return <button key={b} onClick={() => setBoard(b)} style={{ padding: '7px 14px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{b}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>제목</div>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={isParty ? '예: 저녁 9시 파티 랭크전 같이 하실 분' : '제목을 입력하세요'} style={inputBase} />
|
||||
</div>
|
||||
|
||||
{/* 모집 유형 */}
|
||||
{isParty && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>모집 유형</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{CM_PARTY_TYPES.map((t) => (
|
||||
<button key={t} onClick={() => setPtype(t)} style={chip(ptype === t)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 참여 조건 */}
|
||||
{isParty && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={label}>참여 조건</div>
|
||||
<button onClick={() => setVoice((v) => !v)} style={chip(voice)}>보이스 채팅 필수</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={label}>내용</div>
|
||||
<textarea value={body} onChange={(e) => setBody(e.target.value)} rows={10} placeholder={isParty ? '활동 시간대, 분위기, 디스코드/오픈카톡 연락 방법 등을 적어주세요.' : '내용을 입력하세요.'} style={{ ...inputBase, resize: 'vertical', lineHeight: 1.7 }} />
|
||||
</div>
|
||||
|
||||
{/* 사진 첨부 */}
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { if (e.target.files && e.target.files.length) setHasImage(true); e.target.value = ''; }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||
<button onClick={() => fileRef.current && fileRef.current.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 13px', borderRadius: 7, cursor: 'pointer', background: '#fff', border: '1px dashed var(--line2)', color: 'var(--t2)', fontWeight: 700, fontSize: 12, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||
사진 첨부
|
||||
</button>
|
||||
{hasImage && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--t2)', fontWeight: 600 }}>
|
||||
사진 1장 첨부됨
|
||||
<button onClick={() => setHasImage(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--t3)', fontSize: 14, padding: 0 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--line)', paddingTop: 16 }}>
|
||||
<button onClick={onCancel} style={{ flex: 1, padding: '12px 0', borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 14, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>취소</button>
|
||||
<button onClick={submit} disabled={!valid} style={{ flex: 2, padding: '12px 0', borderRadius: 7, border: 'none', background: valid ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 14, cursor: valid ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif" }}>등록하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 커뮤니티 화면 ──
|
||||
function CommunityScreen({ onClose, onLogin, onClan, onCommunity }) {
|
||||
const [per, setPer] = React.useState(15);
|
||||
const [board, setBoard] = React.useState('전체');
|
||||
const [ptypeF, setPtypeF] = React.useState('전체');
|
||||
const [voiceF, setVoiceF] = React.useState('전체');
|
||||
const [q, setQ] = React.useState('');
|
||||
const [qScope, setQScope] = React.useState('제목');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [view, setView] = React.useState(null);
|
||||
const [writing, setWriting] = React.useState(false);
|
||||
const [extra, setExtra] = React.useState([]); // 새로 쓴 글
|
||||
|
||||
const allPosts = [...extra, ...CM_POSTS];
|
||||
let list = board === '전체' ? allPosts : allPosts.filter((p) => p.board === board);
|
||||
if (board === '파티 모집') {
|
||||
if (ptypeF !== '전체') list = list.filter((p) => p.party && p.party.ptype === ptypeF);
|
||||
if (voiceF !== '전체') list = list.filter((p) => p.party && (voiceF === '필수' ? p.party.voice : !p.party.voice));
|
||||
}
|
||||
if (q.trim()) {
|
||||
const k = q.trim();
|
||||
list = list.filter((p) =>
|
||||
qScope === '제목' ? p.title.includes(k)
|
||||
: qScope === '작성자' ? p.author.includes(k)
|
||||
: (CM_BODIES[p.board] || '').includes(k)
|
||||
);
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(list.length / per));
|
||||
const cur = Math.min(page, totalPages);
|
||||
const rows = list.slice((cur - 1) * per, cur * per);
|
||||
const goPage = (n) => setPage(Math.max(1, Math.min(totalPages, n)));
|
||||
|
||||
const hot = [...CM_POSTS].sort((a, b) => b.views - a.views).slice(0, 5);
|
||||
const best = [...CM_POSTS].sort((a, b) => b.up - a.up).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg2)', display: 'flex', flexDirection: 'column', animation: 'sgFade .22s ease' }}>
|
||||
<window.Nav active="커뮤니티" onLogin={onLogin} onClan={onClan} />
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '24px 28px 46px' }}>
|
||||
{/* 타이틀 + 검색 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: CMCH, fontSize: 10.5, fontWeight: 700, letterSpacing: '2.2px', color: 'var(--t3)' }}>COMMUNITY</div>
|
||||
<h1 style={{ margin: '3px 0 0', fontSize: 24, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.4px' }}>커뮤니티</h1>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select value={qScope} onChange={(e) => { setQScope(e.target.value); setPage(1); }} style={{ fontFamily: "'Pretendard',sans-serif", fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', borderRadius: 7, padding: '9px 10px', background: '#fff', cursor: 'pointer' }}>
|
||||
{['제목', '작성자', '내용'].map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<div style={{ position: 'relative', width: 250 }}>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', display: 'flex' }}><Icon name="search" size={15} stroke="var(--t3)" /></span>
|
||||
<input value={q} onChange={(e) => { setQ(e.target.value); setPage(1); }} placeholder={`${qScope} 검색`}
|
||||
style={{ width: '100%', height: 38, paddingLeft: 36, paddingRight: 12, borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', fontSize: 13, outline: 'none', fontFamily: "'Pretendard',sans-serif", color: 'var(--ink)', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => { setWriting(true); setView(null); }} style={{ padding: '10px 18px', borderRadius: 7, border: 'none', background: CMPT, color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>글쓰기</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 18, alignItems: 'start' }}>
|
||||
{/* 본문: 글쓰기 / 상세 / 게시판 */}
|
||||
{writing ? (
|
||||
<CmWrite initialBoard={board} onCancel={() => setWriting(false)} onSubmit={(p) => {
|
||||
const np = { ...p, id: 100000 + extra.length };
|
||||
setExtra([np, ...extra]);
|
||||
setWriting(false);
|
||||
setBoard(p.board);
|
||||
setPage(1);
|
||||
setView(np);
|
||||
}} />
|
||||
) : view ? <CmPostDetail p={view} onBack={() => setView(null)} /> : (
|
||||
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
{/* 게시판 탭 */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '12px 14px', borderBottom: '1px solid var(--line)', overflowX: 'auto', whiteSpace: 'nowrap' }}>
|
||||
{['전체', ...CM_BOARDS].map((b) => {
|
||||
const on = board === b;
|
||||
return (
|
||||
<button key={b} onClick={() => { setBoard(b); setPage(1); if (b !== '파티 모집') { setPtypeF('전체'); setVoiceF('전체'); } }} style={{ flexShrink: 0, padding: '6px 14px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5,
|
||||
border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{b}</button>
|
||||
);
|
||||
})}
|
||||
<span style={{ marginLeft: 'auto', alignSelf: 'center', fontSize: 11.5, color: 'var(--t3)', fontWeight: 600, flexShrink: 0 }}>{list.length}개 글</span>
|
||||
<select value={per} onChange={(e) => { setPer(Number(e.target.value)); setPage(1); }} style={{ alignSelf: 'center', flexShrink: 0, fontFamily: "'Pretendard',sans-serif", fontSize: 11.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', borderRadius: 5, padding: '5px 7px', background: '#fff', cursor: 'pointer' }}>
|
||||
{[15, 30, 50].map((n) => <option key={n} value={n}>{n}개씩</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 파티 모집 조건 필터 */}
|
||||
{board === '파티 모집' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: '1px solid var(--line)', background: '#fafbfc', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', flexShrink: 0 }}>모집 유형</span>
|
||||
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
||||
{['전체', ...CM_PARTY_TYPES].map((t) => {
|
||||
const on = ptypeF === t;
|
||||
return <button key={t} onClick={() => { setPtypeF(t); setPage(1); }} style={{ padding: '5px 11px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>;
|
||||
})}
|
||||
</div>
|
||||
<span style={{ width: 1, height: 16, background: 'var(--line2)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', flexShrink: 0 }}>보이스</span>
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
{['전체', '필수', '자율'].map((t) => {
|
||||
const on = voiceF === t;
|
||||
return <button key={t} onClick={() => { setVoiceF(t); setPage(1); }} style={{ padding: '5px 11px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>;
|
||||
})}
|
||||
</div>
|
||||
{(ptypeF !== '전체' || voiceF !== '전체') && (
|
||||
<button onClick={() => { setPtypeF('전체'); setVoiceF('전체'); setPage(1); }} style={{ marginLeft: 'auto', border: 'none', background: 'transparent', color: 'var(--t2)', fontWeight: 700, fontSize: 11.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", textDecoration: 'underline', flexShrink: 0 }}>초기화</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CM_NOTICES.map((n, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', background: '#f4f6f3', borderBottom: '1px solid #e6e9e4', cursor: 'pointer' }}>
|
||||
<span style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 800, padding: '1px 7px', borderRadius: 4, background: CMPT, color: '#fff', minWidth: 44, textAlign: 'center', boxSizing: 'border-box' }}>공지</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, color: 'var(--ink)', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.title}</span>
|
||||
<span style={{ flexShrink: 0, fontSize: 11.5, color: 'var(--t3)', fontWeight: 600 }}>{n.time}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 목록 */}
|
||||
{rows.map((p, i) => <CmRow key={p.id} p={p} zebra={i % 2 === 1} onOpen={setView} />)}
|
||||
{rows.length === 0 && (
|
||||
<div style={{ padding: '36px 0', textAlign: 'center', fontSize: 13, color: 'var(--t3)' }}>검색 결과가 없어요.</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '14px 0' }}>
|
||||
<button onClick={() => goPage(cur - 1)} disabled={cur === 1} style={{ width: 30, height: 30, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: cur === 1 ? 'default' : 'pointer', opacity: cur === 1 ? .4 : 1, display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="chevL" size={14} stroke="var(--t2)" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button key={n} onClick={() => goPage(n)} style={{ minWidth: 30, height: 30, padding: '0 6px', borderRadius: 6, cursor: 'pointer', fontFamily: CMCH, fontWeight: 700, fontSize: 12.5,
|
||||
border: n === cur ? 'none' : '1px solid var(--line2)', background: n === cur ? CMPT : '#fff', color: n === cur ? '#fff' : 'var(--t2)' }}>{n}</button>
|
||||
))}
|
||||
<button onClick={() => goPage(cur + 1)} disabled={cur === totalPages} style={{ width: 30, height: 30, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: cur === totalPages ? 'default' : 'pointer', opacity: cur === totalPages ? .4 : 1, display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="chevR" size={14} stroke="var(--t2)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사이드 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<CmSidePanel title="인기글" en="HOT" posts={hot} valueOf={(p) => `조회 ${window.SA.fmt(p.views)}`} onOpen={setView} />
|
||||
<CmSidePanel title="주간 베스트" en="WEEKLY BEST" posts={best} valueOf={(p) => `추천 ${p.up}`} onOpen={setView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { CommunityScreen });
|
||||
@@ -0,0 +1,490 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 서든어택 전적검색 — 더미 데이터 (서든어택 세계관 기반)
|
||||
// 군대 계급, 폭파미션/생존전/클랜전, 한국식 FPS 닉/맵
|
||||
// window.SA 로 노출
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 군대 계급(랭크) 사다리 — 서든어택 특유의 '계급' 시스템
|
||||
const RANKS = {
|
||||
이병: { name: '이병', group: '사병', tier: 1, color: '#737d8a', pips: 1, shape: 'bar' },
|
||||
일병: { name: '일병', group: '사병', tier: 2, color: '#6b7682', pips: 2, shape: 'bar' },
|
||||
상병: { name: '상병', group: '사병', tier: 3, color: '#5e6975', pips: 3, shape: 'bar' },
|
||||
병장: { name: '병장', group: '사병', tier: 4, color: '#525c68', pips: 4, shape: 'bar' },
|
||||
하사: { name: '하사', group: '부사관', tier: 5, color: '#b3712b', pips: 1, shape: 'chev' },
|
||||
중사: { name: '중사', group: '부사관', tier: 6, color: '#ac6c28', pips: 2, shape: 'chev' },
|
||||
상사: { name: '상사', group: '부사관', tier: 7, color: '#a56625', pips: 3, shape: 'chev' },
|
||||
원사: { name: '원사', group: '부사관', tier: 8, color: '#9e6022', pips: 4, shape: 'chev' },
|
||||
준위: { name: '준위', group: '준사관', tier: 9, color: '#566f8e', pips: 1, shape: 'dia' },
|
||||
소위: { name: '소위', group: '위관', tier: 10, color: '#4f6b8a', pips: 1, shape: 'dia' },
|
||||
중위: { name: '중위', group: '위관', tier: 11, color: '#486485', pips: 2, shape: 'dia' },
|
||||
대위: { name: '대위', group: '위관', tier: 12, color: '#415d7e', pips: 3, shape: 'dia' },
|
||||
소령: { name: '소령', group: '영관', tier: 13, color: '#c0901a', pips: 1, shape: 'flower' },
|
||||
중령: { name: '중령', group: '영관', tier: 14, color: '#b78812', pips: 2, shape: 'flower' },
|
||||
대령: { name: '대령', group: '영관', tier: 15, color: '#ad800c', pips: 3, shape: 'flower' },
|
||||
준장: { name: '준장', group: '장성', tier: 16, color: '#9c7b1e', pips: 1, shape: 'star' },
|
||||
소장: { name: '소장', group: '장성', tier: 17, color: '#937319', pips: 2, shape: 'star' },
|
||||
중장: { name: '중장', group: '장성', tier: 18, color: '#8a6c16', pips: 3, shape: 'star' },
|
||||
대장: { name: '대장', group: '장성', tier: 19, color: '#7d6111', pips: 4, shape: 'star' },
|
||||
};
|
||||
|
||||
const MAPS = ['제3보급창고', '웨어하우스', '데드폴', '웨스트게이트', '가스폭발', '6th 어벤터', '컨테이너시티', '욕망의 항구', '라스트포트', '폐건물', '지하철', '눈보라 기지', '국경지대', '폐쇄병동', '사막의 폭풍', '시가지', '비밀연구소', '공장지대', '항구창고', '폭우의 거리'];
|
||||
function r(n) { return Math.floor(Math.random() * n); }
|
||||
function pick(a) { return a[r(a.length)]; }
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
// 닉네임 풀 (매치 팀 로스터용)
|
||||
const NICK_POOL = ['독수리오형제', '한밤의저격수', '돌격대장', '연사의신', '헤드샷마스터', '클러치킹', '무한리필', 'рато', '광속에임', '벽뚫는소리', '근접의달인', '플래시뱅', '노스코프', '스팀팩', '리코일제로', '원피스원킬', '소음기장착', '전장의지배자', '마지막총알', '검은표범', '강철멘탈', '불꽃튀는키보드', '야시경', '폭파전문가', '리스폰지옥', '엄폐엄폐', '각잡고대기', '스나는예술', '돌진또돌진', '침착한손'];
|
||||
|
||||
// 매치 팀 로스터 생성기
|
||||
function genRoster(meName, mePos, ownerStats, ownerRank) {
|
||||
const used = new Set([meName]);
|
||||
const mk = (isOwner) => {
|
||||
let name;
|
||||
if (isOwner) name = meName;
|
||||
else { do { name = pick(NICK_POOL); } while (used.has(name)); used.add(name); }
|
||||
const k = isOwner ? ownerStats.k : 4 + r(26);
|
||||
const d = isOwner ? ownerStats.d : 5 + r(17);
|
||||
const a = isOwner ? ownerStats.a : 2 + r(12);
|
||||
const rank = isOwner && ownerRank ? ownerRank : pick(RANK_LIST);
|
||||
return { name, isOwner, rank, k, d, a, hs: 14 + r(48), dmg: 1800 + r(5200) };
|
||||
};
|
||||
const blue = [], red = [];
|
||||
for (let i = 0; i < 5; i++) blue.push(mk(mePos === 'blue' && i === 0));
|
||||
for (let i = 0; i < 5; i++) red.push(mk(mePos === 'red' && i === 0));
|
||||
const sortFn = (a, b) => b.k - a.k;
|
||||
return { blue: blue.sort(sortFn), red: red.sort(sortFn) };
|
||||
}
|
||||
|
||||
// 최근 매치 생성기
|
||||
function genMatches(count, base, ownerName, ownerRank) {
|
||||
const out = [];
|
||||
const meName = ownerName || '나';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mode = pick(['클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트']);
|
||||
const win = Math.random() < (base ? base.wr / 100 : 0.55);
|
||||
const k = 8 + r(28);
|
||||
const d = 6 + r(18);
|
||||
const a = 3 + r(12);
|
||||
const hours = i === 0 ? 0 : Math.floor(Math.random() * (i * 5) + i);
|
||||
const winSide = pick(['blue', 'red']);
|
||||
const mePos = win ? winSide : (winSide === 'blue' ? 'red' : 'blue');
|
||||
out.push({
|
||||
mode,
|
||||
map: pick(MAPS),
|
||||
win, k, d, a,
|
||||
teams: genRoster(meName, mePos, { k, d, a }, ownerRank),
|
||||
mePos, winSide,
|
||||
ago: hours === 0 ? '방금 전' : hours < 24 ? `${hours}시간 전` : `${Math.floor(hours / 24)}일 전`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 프로필 확장 전적 생성기 ──
|
||||
const TIERS_DESC = ['하이랭커', '랭커', '챌린저', '그랜드마스터', '마스터', '골드', '실버', '브론즈'];
|
||||
const CLAN_POOL = [['서울제일', 'SEL'], ['불곰부대', 'BEAR'], ['야간전투', 'NGT'], ['한강수비대', 'HAN'], ['벚꽃클랜', 'SAK'], ['강철군화', 'STL'], ['백호부대', 'WHT']];
|
||||
const RANK_LIST = Object.values(RANKS).sort((a, b) => a.tier - b.tier);
|
||||
function clamp(lo, hi, v) { return Math.max(lo, Math.min(hi, Math.round(v))); }
|
||||
function rankStep(rank, d) { const t = Math.max(1, Math.min(RANK_LIST.length, rank.tier + d)); return RANK_LIST.find((x) => x.tier === t) || rank; }
|
||||
function tierByWr(wr, idx) { const i = Math.max(0, Math.min(7, Math.floor((78 - wr) / 4) + idx)); return TIERS_DESC[i]; }
|
||||
function recBase(wrIn) {
|
||||
const wr = clamp(38, 80, wrIn);
|
||||
const mt = 60 + r(260);
|
||||
const w = Math.round(mt * wr / 100);
|
||||
return { matches: mt, w, l: mt - w, wr, kd: clamp(48, 80, wr + r(10) - 4), hs: 24 + r(34) };
|
||||
}
|
||||
function genClanPeriods(isCurrent) {
|
||||
const n = 1 + r(2); // 1~3회 가입
|
||||
const periods = [];
|
||||
let year = 2023 + r(2);
|
||||
let mon = 1 + r(11);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dur = 2 + r(11);
|
||||
const from = `${year}.${pad(mon)}`;
|
||||
let em = mon + dur, ey = year + Math.floor((em - 1) / 12); em = ((em - 1) % 12) + 1;
|
||||
const last = i === n - 1;
|
||||
periods.push({ from, to: (last && isCurrent) ? '활동 중' : `${ey}.${pad(em)}`, months: dur });
|
||||
year = ey; mon = em + 1 + r(6); if (mon > 12) { year += 1; mon -= 12; }
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
function genClanRecords(o) {
|
||||
const out = []; const seen = new Set();
|
||||
if (o.clan) { out.push({ clan: o.clan, tag: o.clanTag, ...recBase(o.wr), periods: genClanPeriods(true) }); seen.add(o.clan); }
|
||||
const pool = CLAN_POOL.filter((c) => !seen.has(c[0]));
|
||||
const n = 2 + r(3);
|
||||
for (let i = 0; i < n && i < pool.length; i++) out.push({ clan: pool[i][0], tag: pool[i][1], ...recBase(o.wr - 3 - r(8)), periods: genClanPeriods(false) });
|
||||
return out;
|
||||
}
|
||||
function rankedRec(wrIn, tier) {
|
||||
const apex = tier === '하이랭커';
|
||||
return { tier, div: apex ? null : (1 + r(4)), points: apex ? (2200 + r(2600)) : (300 + r(1600)), ...recBase(wrIn) };
|
||||
}
|
||||
function genRankedSeasons(o) {
|
||||
const seasons = ['2026 시즌2', '2026 시즌1', '2025 시즌4', '2025 시즌3', '2025 시즌2', '2025 시즌1'];
|
||||
return seasons.map((s, idx) => ({
|
||||
season: s,
|
||||
solo: rankedRec(o.wr - idx * 2, tierByWr(o.wr, idx)),
|
||||
party: rankedRec(o.wr - 3 - idx * 2, tierByWr(o.wr - 2, idx)),
|
||||
}));
|
||||
}
|
||||
function genMapRecords(o) {
|
||||
return MAPS.map((name) => ({ name, ...recBase(o.wr + r(10) - 5) })).sort((a, b) => b.matches - a.matches);
|
||||
}
|
||||
function genModeRecords(o) {
|
||||
return [
|
||||
{ name: '솔로 랭크전', ...recBase(o.wr) },
|
||||
{ name: '파티 랭크전', ...recBase(o.wr - 3) },
|
||||
{ name: '클랜전', ...recBase(o.wr - 1) },
|
||||
{ name: '일반전', ...recBase(o.wr - 2 + r(6)) },
|
||||
];
|
||||
}
|
||||
|
||||
function genTrend(o) {
|
||||
const baseWr = clamp(38, 80, o.wr);
|
||||
const baseKd = Math.round((o.kda / (o.kda + 1)) * 100);
|
||||
const series = (n, labeler, vol) => {
|
||||
const out = [];
|
||||
let wr = baseWr - 4 + r(8), kd = baseKd - 4 + r(8);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
wr = clamp(30, 88, wr + (r(2 * vol + 1) - vol));
|
||||
kd = clamp(30, 85, kd + (r(2 * vol + 1) - vol));
|
||||
out.push({ label: labeler(i), wr, kd, games: 6 + r(22) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
daily: series(14, (i) => `${i === 0 ? '오늘' : i + '일 전'}`, 5),
|
||||
weekly: series(10, (i) => `${i === 0 ? '이번주' : i + '주 전'}`, 4),
|
||||
};
|
||||
}
|
||||
|
||||
function mkPlayer(o) {
|
||||
const rank = RANKS[o.rank];
|
||||
const games = o.games || (3000 + r(9000));
|
||||
const wins = Math.round(games * (o.wr / 100));
|
||||
const m = genMatches(14, { wr: o.wr }, o.name, rank);
|
||||
const rifle = 50 + r(22);
|
||||
const sniper = Math.round((100 - rifle) * (0.45 + Math.random() * 0.35));
|
||||
const special = Math.max(0, 100 - rifle - sniper);
|
||||
return {
|
||||
id: o.name,
|
||||
name: o.name,
|
||||
rank,
|
||||
level: o.level || (40 + r(160)),
|
||||
clan: o.clan || null,
|
||||
clanTag: o.clanTag || null,
|
||||
wr: o.wr,
|
||||
kda: o.kda,
|
||||
hs: o.hs, // 헤드샷 %
|
||||
rifle, sniper, special, // 무기 분류별 킬 비중 %
|
||||
rankNoTotal: Math.max(1, Math.round((4.3 - o.kda) * 4200) + r(600)), // 통합 랭킹(위)
|
||||
rankNoSeason: Math.max(1, Math.round((4.3 - o.kda) * 4200 * (0.7 + Math.random() * 0.8)) + r(500)), // 시즌 랭킹(위)
|
||||
manner: 80 + r(18), // 매너 점수
|
||||
connRate: 52 + r(46), // 접속률 %
|
||||
rankTotal: rank, // 통합 계급(피크)
|
||||
rankSeason: rankStep(rank, -1), // 이번 시즌 계급
|
||||
clanRecords: genClanRecords(o), // 소속 클랜별 클랜전 전적
|
||||
rankedSeasons: genRankedSeasons(o), // 시즌별 랭크전(솔로/파티)
|
||||
mapRecords: genMapRecords(o), // 맵별 전적
|
||||
modeRecords: genModeRecords(o), // 모드별 전적
|
||||
games,
|
||||
wins,
|
||||
losses: games - wins,
|
||||
matches: m,
|
||||
together: (() => { const g = 12 + r(180); const w = Math.round(g * (o.wr / 100)); return { games: g, w, l: g - w }; })(),
|
||||
trend: genTrend(o),
|
||||
color: rank.color,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 로그인 유저(나) ──
|
||||
const me = mkPlayer({
|
||||
name: '헤드샷장인', rank: '소령', level: 187, wr: 61, kda: 2.14, hs: 47,
|
||||
clan: '불곰부대', clanTag: 'BEAR', games: 8420,
|
||||
});
|
||||
|
||||
// ── 솔로랭크 TOP ──
|
||||
const soloRanking = [
|
||||
mkPlayer({ name: 'GLOCK_여신', rank: '대장', level: 240, wr: 73, kda: 3.42, hs: 58, clan: '서울제일', clanTag: 'SEL', games: 14200, badge: '챔피언' }),
|
||||
mkPlayer({ name: '서든황제김씨', rank: '중장', level: 233, wr: 70, kda: 3.08, hs: 54, clan: '불곰부대', clanTag: 'BEAR', games: 13100 }),
|
||||
mkPlayer({ name: '컨테이너지박령', rank: '소장', level: 221, wr: 68, kda: 2.91, hs: 61, clan: '야간전투', clanTag: 'NGT', games: 11890 }),
|
||||
mkPlayer({ name: '원킬원샷', rank: '준장', level: 215, wr: 66, kda: 2.77, hs: 63, clan: null, games: 10400 }),
|
||||
mkPlayer({ name: '데드폴터줏대', rank: '대령', level: 208, wr: 64, kda: 2.55, hs: 49, clan: '한강수비대', clanTag: 'HAN', games: 9870 }),
|
||||
mkPlayer({ name: '도라에몽핵아님', rank: '중령', level: 199, wr: 63, kda: 2.48, hs: 52, clan: null, games: 9210 }),
|
||||
mkPlayer({ name: '오로라공주', rank: '소령', level: 191, wr: 62, kda: 2.31, hs: 45, clan: '벚꽃클랜', clanTag: 'SAK', games: 8650 }),
|
||||
mkPlayer({ name: '연막탄장인', rank: '대위', level: 178, wr: 60, kda: 2.22, hs: 41, clan: null, games: 7980 }),
|
||||
];
|
||||
|
||||
// ── 생존전 랭킹 ──
|
||||
const survRanking = [
|
||||
mkPlayer({ name: '최후의1인', rank: '대장', level: 238, wr: 41, kda: 4.10, hs: 50, clan: '서울제일', clanTag: 'SEL', games: 6200, badge: '생존왕' }),
|
||||
mkPlayer({ name: '자기장보다빠름', rank: '중장', level: 226, wr: 38, kda: 3.71, hs: 47, clan: null, games: 5840 }),
|
||||
mkPlayer({ name: '낙하산명사수', rank: '소장', level: 219, wr: 36, kda: 3.40, hs: 44, clan: '야간전투', clanTag: 'NGT', games: 5510 }),
|
||||
mkPlayer({ name: '엄폐의달인', rank: '준장', level: 210, wr: 34, kda: 3.12, hs: 39, clan: null, games: 5120 }),
|
||||
mkPlayer({ name: '캠핑은매너', rank: '대령', level: 203, wr: 32, kda: 2.95, hs: 42, clan: '한강수비대', clanTag: 'HAN', games: 4790 }),
|
||||
mkPlayer({ name: '막판뒤집기', rank: '중령', level: 196, wr: 31, kda: 2.80, hs: 38, clan: null, games: 4510 }),
|
||||
mkPlayer({ name: '보급상자요정', rank: '소령', level: 188, wr: 30, kda: 2.66, hs: 36, clan: '벚꽃클랜', clanTag: 'SAK', games: 4230 }),
|
||||
mkPlayer({ name: '치킨한마리', rank: '대위', level: 175, wr: 29, kda: 2.51, hs: 35, clan: null, games: 3980 }),
|
||||
];
|
||||
|
||||
// ── 클랜 랭킹 ──
|
||||
// ── 최근 검색한 유저(빠른 재검색) ──
|
||||
const recentSearches = ['GLOCK_여신', '서든황제김씨', '원킬원샷', '오로라공주', '최후의1인', '연막탄장인'];
|
||||
|
||||
// ── 인기 검색 랭킹(가장 많이 조회된 유저 · 24시간) ──
|
||||
const searchTrending = [
|
||||
{ name: 'GLOCK_여신', count: 18420, move: 0 },
|
||||
{ name: '최후의1인', count: 15330, move: 2 },
|
||||
{ name: '헤드샷장인', count: 12870, move: -1 },
|
||||
{ name: '서든황제김씨', count: 11240, move: 'new' },
|
||||
{ name: '원킬원샷', count: 9680, move: 1 },
|
||||
{ name: '오로라공주', count: 8210, move: -2 },
|
||||
{ name: '컨테이너지박령', count: 7050, move: 'new' },
|
||||
{ name: '연막탄장인', count: 6320, move: 3 },
|
||||
];
|
||||
|
||||
// ── 즐겨찾기(친구/전우) ──
|
||||
const friends = [
|
||||
mkPlayer({ name: '같이가요전우님', rank: '대위', level: 172, wr: 58, kda: 1.98, hs: 38, clan: '불곰부대', clanTag: 'BEAR', online: true }),
|
||||
mkPlayer({ name: '소이탄러버', rank: '중위', level: 154, wr: 55, kda: 1.81, hs: 35, clan: '불곰부대', clanTag: 'BEAR', online: true }),
|
||||
mkPlayer({ name: '야1돌이', rank: '상사', level: 121, wr: 52, kda: 1.62, hs: 33, clan: null, online: false }),
|
||||
mkPlayer({ name: '닷지의신', rank: '소위', level: 138, wr: 54, kda: 1.74, hs: 30, clan: '불곰부대', clanTag: 'BEAR', online: false }),
|
||||
];
|
||||
|
||||
// ── 실시간 전장 현황(오늘) ──
|
||||
const liveStats = {
|
||||
date: '06월 09일',
|
||||
totalMatches: 151873,
|
||||
matchModes: [
|
||||
{ name: '클랜전', count: 40713 },
|
||||
{ name: '솔로 랭크전', count: 62840 },
|
||||
{ name: '파티 랭크전', count: 48320 },
|
||||
{ name: '클랜 랭크전', count: 21560 },
|
||||
],
|
||||
online: 31204,
|
||||
topMap: '제3보급창고',
|
||||
topMode: '폭파미션',
|
||||
newClans: 142,
|
||||
bannedToday: 87, // 핵/불량 유저 제재
|
||||
prevDelta: +6.2, // 전일대비 %
|
||||
};
|
||||
|
||||
// ── 커뮤니티 게시판 ──
|
||||
const community = {
|
||||
자유: [
|
||||
{ tag: '유머', title: '제3보급창고 A롱 각도 외운 사람만 아는 그것 ㅋㅋㅋ', cmt: 214, hot: true, time: '12분 전' },
|
||||
{ tag: '질문', title: '20년차 복귀유저인데 요즘 메타 AK가 국룰인가요?', cmt: 88, hot: true, time: '34분 전' },
|
||||
{ tag: '자랑', title: '드디어 소령 달았습니다… 학창시절부터 한 게임 (눈물)', cmt: 156, hot: true, time: '1시간 전' },
|
||||
{ tag: '잡담', title: '오늘 생존전 스쿼드 막판 1:4 역전 클립 보고가셈', cmt: 42, hot: false, time: '2시간 전' },
|
||||
{ tag: '질문', title: '클랜원 모집 어디서 하는 게 제일 빠른가요?', cmt: 19, hot: false, time: '3시간 전' },
|
||||
],
|
||||
공략: [
|
||||
{ tag: '에임', title: '헤드라인 유지 — 입문자가 3일 만에 헤드샷률 올리는 법', cmt: 327, hot: true, time: '6시간 전' },
|
||||
{ tag: '맵', title: '[데드폴] 폭파 공격팀 기본 진입 루트 3가지 총정리', cmt: 198, hot: true, time: '8시간 전' },
|
||||
{ tag: '맵', title: '[웨어하우스] 수비팀 크로스 각 잡는 3가지 포지션', cmt: 142, hot: false, time: '1일 전' },
|
||||
{ tag: '생존', title: '생존전 솔로 — 자기장 사이클별 동선 가이드', cmt: 89, hot: false, time: '1일 전' },
|
||||
{ tag: '클랜', title: '클랜전 입문 — 포지션별 역할과 콜 기본기', cmt: 64, hot: false, time: '2일 전' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── SA 소식: 공지사항 / 업데이트 / 이벤트 ──
|
||||
const news = {
|
||||
공지사항: [
|
||||
{ title: '6/11(목) 정기점검 및 게임 프레임 확장 베타 서버 적용 안내', date: '06.09', pin: true },
|
||||
{ title: '확률형 아이템 확률 정보 통합 안내 페이지 오픈 안내', date: '06.05', pin: true },
|
||||
{ title: '비매너·불법 프로그램 사용 계정 제재 안내 (6월 1주차)', date: '06.04', pin: false },
|
||||
{ title: '서버 안정화 작업에 따른 일부 채널 일시 점검 안내', date: '05.30', pin: false },
|
||||
{ title: '닉네임 변경권 사용 정책 일부 개정 안내', date: '05.27', pin: false },
|
||||
{ title: '고객지원 운영시간 변경 안내', date: '05.22', pin: false },
|
||||
],
|
||||
'GM이야기': [
|
||||
{ title: '[GM레터] 게임 프레임 확장 베타, 직접 체험해본 후기', date: '06.09', tag: 'GM' },
|
||||
{ title: '개발팀이 전하는 2026 시즌2 「페르소나」 기획 비하인드', date: '06.03', tag: 'GM' },
|
||||
{ title: '[GM노트] 밸런스 패치는 어떤 기준으로 결정되나요?', date: '05.27', tag: 'GM' },
|
||||
{ title: '커뮤니티 의견 반영 현황 — 여러분의 제안이 이렇게 적용됐어요', date: '05.20', tag: 'GM' },
|
||||
{ title: '[GM레터] 비매너·핵 근절을 위한 모니터링 강화 이야기', date: '05.13', tag: 'GM' },
|
||||
{ title: '신규·복귀 유저를 위한 GM의 온보딩 팁 모음', date: '05.06', tag: 'GM' },
|
||||
],
|
||||
업데이트: [
|
||||
{ title: '2026 시즌2 「페르소나」 — 신규 캐릭터 리나·린 추가', date: '06.04', tag: '신규' },
|
||||
{ title: '생존전 로데오 솔로 모드 및 신규 콤보 시스템 적용', date: '05.28', tag: '모드' },
|
||||
{ title: '무기 컬렉션 대표 스킨 시스템 개편', date: '05.21', tag: '시스템' },
|
||||
{ title: '2026 무기 개편 2차 — SG552·FAMAS 밸런스 조정', date: '05.14', tag: '밸런스' },
|
||||
{ title: '정채연 캐릭터 출시 및 전용 모션 추가', date: '05.07', tag: '캐릭터' },
|
||||
{ title: '제3보급창고 리마스터 및 충돌 버그 수정', date: '04.30', tag: '맵' },
|
||||
],
|
||||
이벤트: [
|
||||
{ title: 'SUMMER PARTY TIME! PC방 파티 출석 이벤트', date: '06.08', tag: '진행중' },
|
||||
{ title: '얼리 썸머 체크인 — 7일 출석 시 무기 스킨 지급', date: '06.05', tag: '진행중' },
|
||||
{ title: '2026 챔피언십 시즌2 승부예측 6주차', date: '06.02', tag: '진행중' },
|
||||
{ title: '신규·복귀 전우 환영 미션 패키지', date: '05.26', tag: '상시' },
|
||||
{ title: '친구 초대하고 캐릭터 뽑기권 받기', date: '05.20', tag: '상시' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── 지금 방송 중 LIVE (치지직 / 숲 / 유튜브 연동) ──
|
||||
const streamPlatforms = {
|
||||
치지직: { label: '치지직', en: 'CHZZK', color: '#00b6a0', tag: 'CHZZK' },
|
||||
숲: { label: '숲', en: 'SOOP', color: '#0064ff', tag: 'SOOP' },
|
||||
유튜브: { label: '유튜브', en: 'YOUTUBE', color: '#e62117', tag: 'YT' },
|
||||
};
|
||||
const streams = [
|
||||
{ platform: '치지직', streamer: '헤드샷장인', title: '소령 → 중령 승급전 가즈아 [클랜전 정주행]', viewers: 4210, started: '2시간째' },
|
||||
{ platform: '숲', streamer: 'BJ철벽수비', title: '생존전 1인 스쿼드 치킨 30판 도전', viewers: 2870, started: '1시간째' },
|
||||
{ platform: '유튜브', streamer: '원킬원샷TV', title: 'AWP 원챔으로 솔로랭크 끝까지 등반', viewers: 1530, started: '3시간째' },
|
||||
{ platform: '치지직', streamer: '오로라공주', title: '시청자 참여 클랜전 · 같이 서든해요', viewers: 980, started: '40분째' },
|
||||
{ platform: '숲', streamer: '연막탄장인', title: '데드폴 공격팀 진입 루트 풀이 + 랭크', viewers: 760, started: '1시간째' },
|
||||
{ platform: '유튜브', streamer: '서든레전드', title: '랭크전 티어별 동선·포지션 완전 정리', viewers: 540, started: '2시간째' },
|
||||
];
|
||||
|
||||
// ── 컨텐츠 랭크전 티어 색상표 (브론즈→하이랭커 8단계) ──
|
||||
const tiers = [
|
||||
{ name: '브론즈', color: '#a9743b' },
|
||||
{ name: '실버', color: '#94a0aa' },
|
||||
{ name: '골드', color: '#c4960f' },
|
||||
{ name: '마스터', color: '#5b8a72' },
|
||||
{ name: '그랜드마스터', color: '#4f6b8a' },
|
||||
{ name: '챌린저', color: '#7a5bb0' },
|
||||
{ name: '랭커', color: '#b25a2b' },
|
||||
{ name: '하이랭커', color: '#b23b3b' },
|
||||
];
|
||||
|
||||
// ── SA 랭킹 5종 (각 top10) ──
|
||||
// 1) 시즌 계급 랭킹 — 이번 시즌 계급 점수
|
||||
const seasonClassRanking = [
|
||||
{ name: 'GLOCK_여신', score: 41820 },
|
||||
{ name: '최후의1인', score: 39650 },
|
||||
{ name: '서든황제김씨', score: 37410 },
|
||||
{ name: '자기장보다빠름', score: 35880 },
|
||||
{ name: '컨테이너지박령', score: 34120 },
|
||||
{ name: '낙하산명사수', score: 32540 },
|
||||
{ name: '원킬원샷', score: 30980 },
|
||||
{ name: '엄폐의달인', score: 29360 },
|
||||
{ name: '데드폴터줏대', score: 27910 },
|
||||
{ name: '캠핑은매너', score: 26480 },
|
||||
];
|
||||
// 2) 통합 계급 랭킹 — 역대 누적 계급 점수
|
||||
const totalClassRanking = [
|
||||
{ name: '서든황제김씨', score: 284900 },
|
||||
{ name: 'GLOCK_여신', score: 271350 },
|
||||
{ name: '원킬원샷', score: 258740 },
|
||||
{ name: '컨테이너지박령', score: 246180 },
|
||||
{ name: '데드폴터줏대', score: 233520 },
|
||||
{ name: '도라에몽핵아님', score: 221070 },
|
||||
{ name: '최후의1인', score: 209640 },
|
||||
{ name: '오로라공주', score: 198330 },
|
||||
{ name: '연막탄장인', score: 187020 },
|
||||
{ name: '헤드샷장인', score: 176510 },
|
||||
];
|
||||
// 3) 컨텐츠 랭킹 — 솔로/파티 랭크전 (RP순)
|
||||
const soloRpRanking = [
|
||||
{ name: 'GLOCK_여신', tier: '하이랭커', rp: 4920 },
|
||||
{ name: '원킬원샷', tier: '하이랭커', rp: 4710 },
|
||||
{ name: '컨테이너지박령', tier: '랭커', rp: 4480 },
|
||||
{ name: '서든황제김씨', tier: '랭커', rp: 4350 },
|
||||
{ name: '데드폴터줏대', tier: '챌린저', rp: 4120 },
|
||||
{ name: '도라에몽핵아님', tier: '챌린저', rp: 3980 },
|
||||
{ name: '오로라공주', tier: '그랜드마스터', rp: 3760 },
|
||||
{ name: '연막탄장인', tier: '그랜드마스터', rp: 3610 },
|
||||
{ name: '헤드샷장인', tier: '마스터', rp: 3420 },
|
||||
{ name: '같이가요전우님', tier: '마스터', rp: 3280 },
|
||||
];
|
||||
const partyRpRanking = [
|
||||
{ name: '최후의1인', tier: '하이랭커', rp: 4860 },
|
||||
{ name: 'GLOCK_여신', tier: '하이랭커', rp: 4690 },
|
||||
{ name: '자기장보다빠름', tier: '랭커', rp: 4420 },
|
||||
{ name: '서든황제김씨', tier: '랭커', rp: 4300 },
|
||||
{ name: '낙하산명사수', tier: '챌린저', rp: 4080 },
|
||||
{ name: '원킬원샷', tier: '챌린저', rp: 3940 },
|
||||
{ name: '엄폐의달인', tier: '그랜드마스터', rp: 3720 },
|
||||
{ name: '오로라공주', tier: '그랜드마스터', rp: 3580 },
|
||||
{ name: '캠핑은매너', tier: '마스터', rp: 3360 },
|
||||
{ name: '보급상자요정', tier: '마스터', rp: 3210 },
|
||||
];
|
||||
// 3-1) 랭크전 클랜 랭킹 — 클랜 랭크전 (RP순)
|
||||
const clanRpRanking = [
|
||||
{ name: '서울제일', tag: 'SEL', members: 48, rp: 9840 },
|
||||
{ name: '불곰부대', tag: 'BEAR', members: 52, rp: 9510 },
|
||||
{ name: '야간전투', tag: 'NGT', members: 41, rp: 9180 },
|
||||
{ name: '강철군화', tag: 'STL', members: 50, rp: 8870 },
|
||||
{ name: '한강수비대', tag: 'HAN', members: 45, rp: 8560 },
|
||||
{ name: '한밤의늑대', tag: 'WLF', members: 44, rp: 8240 },
|
||||
{ name: '데드샷연합', tag: 'DSU', members: 39, rp: 7930 },
|
||||
{ name: '벚꽃클랜', tag: 'SAK', members: 38, rp: 7610 },
|
||||
{ name: '폭풍전야', tag: 'STM', members: 42, rp: 7350 },
|
||||
{ name: '은하수비대', tag: 'GLX', members: 36, rp: 7080 },
|
||||
];
|
||||
// 4) 공식 클랜 랭킹 — 운영팀 인증 클랜
|
||||
const officialClanRanking = [
|
||||
{ name: '서울제일', tag: 'SEL', members: 48, rp: 28940 },
|
||||
{ name: '불곰부대', tag: 'BEAR', members: 52, rp: 27110 },
|
||||
{ name: '야간전투', tag: 'NGT', members: 41, rp: 25880 },
|
||||
{ name: '정예타격대', tag: 'ACE', members: 50, rp: 24990 },
|
||||
{ name: '한강수비대', tag: 'HAN', members: 45, rp: 24500 },
|
||||
{ name: '백호부대', tag: 'WHT', members: 47, rp: 23770 },
|
||||
{ name: '벚꽃클랜', tag: 'SAK', members: 38, rp: 23210 },
|
||||
{ name: '강철군화', tag: 'STL', members: 50, rp: 22640 },
|
||||
{ name: '흑표클랜', tag: 'BLP', members: 43, rp: 21980 },
|
||||
{ name: '새벽기습', tag: 'DWN', members: 39, rp: 21330 },
|
||||
];
|
||||
// 5) 클랜 랭킹 — 일반 클랜
|
||||
const generalClanRanking = [
|
||||
{ name: '불곰부대', tag: 'BEAR', members: 52, rp: 26210 },
|
||||
{ name: '한밤의늑대', tag: 'WLF', members: 44, rp: 25540 },
|
||||
{ name: '서울제일', tag: 'SEL', members: 48, rp: 24880 },
|
||||
{ name: '돌격대대', tag: 'CHG', members: 36, rp: 23960 },
|
||||
{ name: '야간전투', tag: 'NGT', members: 41, rp: 23310 },
|
||||
{ name: '폭풍전선', tag: 'STM', members: 40, rp: 22650 },
|
||||
{ name: '벚꽃클랜', tag: 'SAK', members: 38, rp: 21940 },
|
||||
{ name: '강철군화', tag: 'STL', members: 50, rp: 21280 },
|
||||
{ name: '백전노장', tag: 'VET', members: 33, rp: 20710 },
|
||||
{ name: '한강수비대', tag: 'HAN', members: 45, rp: 20150 },
|
||||
];
|
||||
|
||||
// ── 시즌 현황 (시즌 D-day · 보상 · 시즌 맵) ──
|
||||
const season = {
|
||||
name: '2026 시즌2 「페르소나」',
|
||||
period: '05.07 ~ 06.30',
|
||||
dday: 'D-20',
|
||||
progress: 63,
|
||||
reward: '시즌2 한정 칭호 「페르소나」 · 캐릭터 뽑기권 10장',
|
||||
maps5v5: ['제3보급창고', '웨어하우스', '데드폴', '컨테이너시티', '라스트포트'],
|
||||
maps3v3: ['욕망의 항구', '폐건물', '가스폭발', '웨스트게이트'],
|
||||
};
|
||||
// ── 연승 핫스트릭 (폼 좋은 유저) ──
|
||||
const hotStreak = [
|
||||
{ name: 'GLOCK_여신', streak: 12 },
|
||||
{ name: '원킬원샷', streak: 9 },
|
||||
{ name: '컨테이너지박령', streak: 8 },
|
||||
{ name: '오로라공주', streak: 7 },
|
||||
{ name: '서든황제김씨', streak: 6 },
|
||||
{ name: '데드폴터줏대', streak: 6 },
|
||||
];
|
||||
|
||||
// ── 진행중인 이벤트 배너(피처드) ──
|
||||
const events = [
|
||||
{ title: '게임 프레임 확장 베타 테스트', sub: '최대 200 FPS 개선 서버를 일주일간 체험', period: '06.04 ~ 06.11', dday: 'D-2', tag: 'GM이야기', kind: 'beta' },
|
||||
{ title: 'SUMMER PARTY TIME!', sub: 'PC방 접속하고 파티 보상 받아가세요', period: '06.01 ~ 06.30', dday: 'D-21', tag: 'PC방', kind: 'party' },
|
||||
{ title: '시즌2 「페르소나」 승부예측', sub: '6주차 매치 결과 맞히고 포인트 적립', period: '~ 06.15', dday: 'D-6', tag: '리그', kind: 'league' },
|
||||
{ title: '얼리 썸머 체크인', sub: '7일 연속 출석 시 한정 무기 스킨 증정', period: '06.05 ~ 06.19', dday: 'D-10', tag: '출석', kind: 'checkin' },
|
||||
{ title: '신규·복귀 유저 부스트', sub: '경험치 2배 + 스타터 보급 패키지 지급', period: '06.01 ~ 06.30', dday: 'D-21', tag: '복귀', kind: 'boost' },
|
||||
{ title: '주말 클랜전 더블 포인트', sub: '토·일 클랜전 승리 시 클랜 포인트 2배', period: '매주 주말', dday: '상시', tag: '클랜', kind: 'clan' },
|
||||
{ title: '핫타임 드롭 이벤트', sub: '매일 20~22시 접속 시 랜덤 보급상자', period: '06.01 ~ 06.30', dday: 'D-21', tag: '핫타임', kind: 'hot' },
|
||||
{ title: '명사수 챌린지', sub: '주간 헤드샷 목표 달성하고 보상 획득', period: '06.09 ~ 06.16', dday: 'D-7', tag: '챌린지', kind: 'challenge' },
|
||||
];
|
||||
|
||||
window.SA = {
|
||||
me, soloRanking, survRanking,
|
||||
recentSearches, friends, liveStats, community, news, events,
|
||||
streams, streamPlatforms, searchTrending, tiers,
|
||||
seasonClassRanking, totalClassRanking, soloRpRanking, partyRpRanking, clanRpRanking,
|
||||
officialClanRanking, generalClanRanking, season, hotStreak,
|
||||
// 즐겨찾기 (페이지네이션 시연용 — 친구 + 랭커 혼합)
|
||||
favorites: [...friends, ...soloRanking, ...survRanking.slice(1, 6)],
|
||||
// 전체 검색 대상 풀
|
||||
allPlayers: [me, ...soloRanking, ...survRanking, ...friends],
|
||||
fmt: (n) => n.toLocaleString('en-US'),
|
||||
kdPct: (kda) => Math.round((kda / (kda + 1)) * 100),
|
||||
};
|
||||
@@ -0,0 +1,691 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 메인 홈 (레트로 구조형 · 올리브 포인트 · 각진 패널 레이아웃)
|
||||
// 공지/업데이트/이벤트 전면 · 이벤트 카드 이미지 슬롯
|
||||
// window.Home
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const { useState: useStateH } = React;
|
||||
const PT = '#48515f'; // 올리브/카키 포인트
|
||||
const PT_SOFT = 'rgba(72,81,95,.12)';
|
||||
const HEAD = '#f1f3f5'; // 패널 헤더바 배경
|
||||
const CH = "'Chakra Petch',sans-serif";
|
||||
|
||||
const panel = { border: '1px solid var(--line2)', background: '#fff', borderRadius: 5 };
|
||||
|
||||
// 패널 (채워진 헤더바 + 보더)
|
||||
function Panel({ title, en, right, children, bodyPad = '8px 14px 12px' }) {
|
||||
return (
|
||||
<section style={panel}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: 40, padding: '0 12px 0 0', background: HEAD, borderBottom: '1px solid var(--line2)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, minWidth: 0 }}>
|
||||
<span style={{ width: 4, alignSelf: 'stretch', background: PT, margin: '8px 0', marginRight: 2 }} />
|
||||
<span style={{ fontSize: 14.5, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.2px' }}>{title}</span>
|
||||
{en && <span style={{ fontFamily: CH, fontSize: 10, fontWeight: 600, color: 'var(--t3)', letterSpacing: '1.2px' }}>{en}</span>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
<div style={{ padding: bodyPad }}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 세그먼트 탭 (각진)
|
||||
function Seg({ tabs, value, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', border: '1px solid var(--line2)', borderRadius: 4, overflow: 'hidden', background: '#fff' }}>
|
||||
{tabs.map((t, i) => {
|
||||
const on = value === t;
|
||||
return (
|
||||
<button key={t} onClick={() => onChange(t)} style={{ padding: '5px 11px', border: 'none', borderLeft: i ? '1px solid var(--line2)' : 'none', cursor: 'pointer',
|
||||
fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? PT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 상단 유틸 바 ──
|
||||
function TopBar() {
|
||||
return (
|
||||
<div style={{ height: 30, background: '#23262b', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '0 28px', gap: 0 }}>
|
||||
{['로그인', '회원가입', '쿠폰등록', '고객센터'].map((t, i) => (
|
||||
<React.Fragment key={t}>
|
||||
{i > 0 && <span style={{ width: 1, height: 11, background: 'rgba(255,255,255,.18)', margin: '0 12px' }} />}
|
||||
<a style={{ fontSize: 12, color: 'rgba(255,255,255,.72)', cursor: 'pointer', fontWeight: 500 }}>{t}</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 네비(GNB) ──
|
||||
function Nav({ onLogin, onClan, onCommunity, active = '홈' }) {
|
||||
const items = ['홈', '전적검색', '커뮤니티', '클랜'];
|
||||
return (
|
||||
<div style={{ background: '#fff', borderBottom: '2px solid #23262b', position: 'sticky', top: 0, zIndex: 30 }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', height: 60, display: 'flex', alignItems: 'center', padding: '0 28px' }}>
|
||||
<a href="searchgg 메인 홈.html" style={{ textDecoration: 'none', display: 'flex' }}><Logo size={22} /></a>
|
||||
<nav style={{ display: 'flex', marginLeft: 34, height: '100%' }}>
|
||||
{items.map((t) => {
|
||||
const on = t === active;
|
||||
const style = { display: 'flex', alignItems: 'center', padding: '0 18px', height: '100%', fontSize: 15, fontWeight: on ? 800 : 600, textDecoration: 'none',
|
||||
color: on ? PT : '#3a4049', cursor: 'pointer', borderBottom: on ? `3px solid ${PT}` : '3px solid transparent', background: 'transparent', border: 'none', borderBottomWidth: 3, borderBottomStyle: 'solid', borderBottomColor: on ? PT : 'transparent', fontFamily: "'Pretendard',sans-serif" };
|
||||
if (t === '클랜' && onClan) {
|
||||
return <button key={t} onClick={onClan} style={style}>{t}</button>;
|
||||
}
|
||||
if (t === '커뮤니티' && onCommunity) {
|
||||
return <button key={t} onClick={onCommunity} style={style}>{t}</button>;
|
||||
}
|
||||
return (
|
||||
<a key={t} href="searchgg 메인 홈.html" style={style}>{t}</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button onClick={onLogin} style={{ marginLeft: 'auto', border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
color: '#3a4049', fontWeight: 700, fontSize: 14, padding: '6px 4px', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
로그인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 검색 히어로(구조형 밴드) ──
|
||||
function Hero({ onPick }) {
|
||||
const SA = window.SA, me = SA.me;
|
||||
return (
|
||||
<div style={{ background: '#fff', borderBottom: '1px solid var(--line2)' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '34px 28px 30px' }}>
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: CH, fontSize: 11, fontWeight: 700, letterSpacing: '3px', color: PT, marginBottom: 10 }}>SUDDEN ATTACK RECORD SEARCH</div>
|
||||
<h1 style={{ margin: '0 0 6px', fontWeight: 800, fontSize: 30, lineHeight: 1.2, letterSpacing: '-1px', color: 'var(--ink)' }}>전적 검색</h1>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--t2)', marginBottom: 18 }}>어서와요, <b style={{ color: PT }}>{me.name}</b> 님</div>
|
||||
<SearchBar big onPick={onPick} />
|
||||
<div style={{ marginTop: 13, display: 'flex', justifyContent: 'center' }}><RecentChips onPick={onPick} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 섹션 헤더(채워진 바) ──
|
||||
function BarHead({ title, en, more }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#23262b', color: '#fff', padding: '0 14px', height: 38, borderRadius: '5px 5px 0 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 9 }}>
|
||||
<span style={{ fontSize: 14.5, fontWeight: 800, letterSpacing: '-.2px' }}>{title}</span>
|
||||
{en && <span style={{ fontFamily: CH, fontSize: 10, fontWeight: 600, color: 'rgba(255,255,255,.5)', letterSpacing: '1.2px' }}>{en}</span>}
|
||||
</div>
|
||||
{more && <a style={{ fontSize: 12, color: 'rgba(255,255,255,.7)', cursor: 'pointer', fontWeight: 600 }}>전체보기 +</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 진행중인 이벤트 (캐러셀) ──
|
||||
function EventStrip() {
|
||||
const SA = window.SA;
|
||||
const PER = 4;
|
||||
const pages = Math.ceil(SA.events.length / PER);
|
||||
const [page, setPage] = useStateH(0);
|
||||
const [hover, setHover] = useStateH(false);
|
||||
React.useEffect(() => {
|
||||
if (hover || pages <= 1) return;
|
||||
const t = setInterval(() => setPage((p) => (p + 1) % pages), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [hover, pages]);
|
||||
const go = (d) => setPage((p) => (p + d + pages) % pages);
|
||||
const arrow = (dir) => (
|
||||
<button onClick={() => go(dir === 'L' ? -1 : 1)} style={{ width: 26, height: 26, borderRadius: 4, border: 'none', background: 'rgba(255,255,255,.14)', color: '#fff', cursor: 'pointer', display: 'grid', placeItems: 'center', fontSize: 13 }}>
|
||||
{dir === 'L' ? '‹' : '›'}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line2)', borderRadius: 5, marginBottom: 18, background: '#fff', overflow: 'hidden' }}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#23262b', color: '#fff', padding: '0 12px 0 14px', height: 38 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 9 }}>
|
||||
<span style={{ fontSize: 14.5, fontWeight: 800, letterSpacing: '-.2px' }}>진행중인 이벤트</span>
|
||||
<span style={{ fontFamily: CH, fontSize: 10, fontWeight: 600, color: 'rgba(255,255,255,.5)', letterSpacing: '1.2px' }}>ON-GOING EVENT</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontFamily: CH, fontSize: 11.5, fontWeight: 700, color: 'rgba(255,255,255,.7)' }}>{page + 1} / {pages}</span>
|
||||
{arrow('L')}{arrow('R')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: `${pages * 100}%`, transform: `translateX(-${page * (100 / pages)}%)`, transition: 'transform .45s cubic-bezier(.4,0,.2,1)' }}>
|
||||
{Array.from({ length: pages }).map((_, pi) => (
|
||||
<div key={pi} style={{ width: `${100 / pages}%`, display: 'grid', gridTemplateColumns: 'repeat(4,1fr)' }}>
|
||||
{SA.events.slice(pi * PER, pi * PER + PER).map((e, i) => {
|
||||
const gi = pi * PER + i;
|
||||
const urgent = e.dday.startsWith('D-') && +e.dday.slice(2) <= 3;
|
||||
return (
|
||||
<div key={e.title} style={{ borderLeft: i ? '1px solid var(--line)' : 'none', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}>
|
||||
<image-slot id={`evt-${gi}`} shape="rect" placeholder={`${e.tag} 배너 이미지를 끌어다 놓으세요`} style={{ display: 'block', width: '100%', height: 112, borderBottom: '1px solid var(--line)' }}></image-slot>
|
||||
<div style={{ padding: '11px 13px 13px', display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
|
||||
<span style={{ fontFamily: "'Pretendard',sans-serif", fontSize: 11, fontWeight: 700, color: '#fff', background: PT, padding: '2px 9px', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.tag}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: CH, fontSize: 11.5, fontWeight: 700, color: urgent ? '#b23b3b' : PT }}>{e.dday}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.34, marginBottom: 5 }}>{e.title}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t2)', lineHeight: 1.45, marginBottom: 'auto' }}>{e.sub}</div>
|
||||
<div style={{ fontFamily: CH, fontSize: 11, color: 'var(--t3)', marginTop: 11, paddingTop: 9, borderTop: '1px solid var(--line)' }}>{e.period}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, padding: '11px 0', borderTop: '1px solid var(--line)' }}>
|
||||
{Array.from({ length: pages }).map((_, i) => (
|
||||
<button key={i} onClick={() => setPage(i)} style={{ width: i === page ? 20 : 7, height: 7, borderRadius: 4, border: 'none', background: i === page ? PT : 'var(--line2)', cursor: 'pointer', transition: 'all .3s', padding: 0 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowDiv = (i, n) => ({ borderBottom: i < n - 1 ? '1px solid #eaecef' : 'none' });
|
||||
|
||||
// ── 지금 방송 중 LIVE (치지직 / 숲 / 유튜브) ──
|
||||
function StreamLive({ onPick }) {
|
||||
const SA = window.SA;
|
||||
const [tab, setTab] = useStateH('전체');
|
||||
const list = tab === '전체' ? SA.streams : SA.streams.filter((s) => s.platform === tab);
|
||||
const totalView = SA.streams.reduce((a, s) => a + s.viewers, 0);
|
||||
return (
|
||||
<Panel title="서든 LIVE" en="STREAMING" bodyPad="12px 14px 14px"
|
||||
right={<Seg tabs={['전체', '치지직', '숲', '유튜브']} value={tab} onChange={setTab} />}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 11, fontSize: 11.5, color: 'var(--t2)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontWeight: 700, color: '#e5484d' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 4, background: '#e5484d', display: 'inline-block', animation: 'sgFade .01s' }} />LIVE
|
||||
</span>
|
||||
<span>{list.length}개 방송</span>
|
||||
<span style={{ color: 'var(--t3)' }}>·</span>
|
||||
<span>시청자 <b style={{ color: 'var(--ink)', fontFamily: CH }}>{SA.fmt(totalView)}</b>명</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 12 }}>
|
||||
{list.map((s, i) => {
|
||||
const pf = SA.streamPlatforms[s.platform];
|
||||
const player = SA.allPlayers.find((p) => p.name === s.streamer);
|
||||
return (
|
||||
<button key={s.streamer + i} onClick={() => player && onPick(player)} style={{ display: 'flex', flexDirection: 'column', textAlign: 'left',
|
||||
border: '1px solid var(--line2)', borderRadius: 5, overflow: 'hidden', background: '#fff', cursor: player ? 'pointer' : 'default', padding: 0, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<image-slot id={`live-${i}`} shape="rect" placeholder={`${s.streamer} 방송 썸네일`} style={{ display: 'block', width: '100%', aspectRatio: '7 / 4' }}></image-slot>
|
||||
<span style={{ position: 'absolute', top: 7, left: 7, display: 'inline-flex', alignItems: 'center', gap: 4, background: '#e5484d', color: '#fff', fontFamily: CH, fontWeight: 700, fontSize: 9.5, letterSpacing: '.6px', padding: '2px 6px', borderRadius: 3 }}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: 3, background: '#fff', display: 'inline-block' }} />LIVE
|
||||
</span>
|
||||
<span style={{ position: 'absolute', top: 7, right: 7, background: pf.color, color: '#fff', fontFamily: CH, fontWeight: 700, fontSize: 9.5, letterSpacing: '.5px', padding: '2px 7px', borderRadius: 3 }}>{pf.tag}</span>
|
||||
<span style={{ position: 'absolute', bottom: 7, right: 7, background: 'rgba(20,22,28,.82)', color: '#fff', fontFamily: CH, fontWeight: 700, fontSize: 10.5, padding: '2px 7px', borderRadius: 3 }}>👁 {SA.fmt(s.viewers)}</span>
|
||||
</div>
|
||||
<div style={{ padding: '9px 10px 10px' }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.34, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', minHeight: 33 }}>{s.title}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 7 }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 800, color: pf.color, letterSpacing: '.3px' }}>{pf.label}</span>
|
||||
<span style={{ width: 1, height: 9, background: 'var(--line2)' }} />
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--t2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.streamer}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10.5, color: 'var(--t3)', flexShrink: 0 }}>{s.started}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── SA 소식 ──
|
||||
function SANews() {
|
||||
const SA = window.SA;
|
||||
const [tab, setTab] = useStateH('공지사항');
|
||||
const rows = SA.news[tab];
|
||||
return (
|
||||
<Panel title="SA 소식" en="NOTICE" right={<Seg tabs={['공지사항', 'GM이야기', '업데이트']} value={tab} onChange={setTab} />} bodyPad="2px 14px 6px">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 2px', ...rowDiv(i, rows.length), cursor: 'pointer' }}>
|
||||
<span style={{ fontFamily: CH, fontSize: 12.5, color: 'var(--t3)', fontWeight: 600, width: 42, flexShrink: 0 }}>{r.date}</span>
|
||||
<span style={{ flex: 1, fontSize: 14, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 커뮤니티 ──
|
||||
function Community({ onAll }) {
|
||||
const SA = window.SA;
|
||||
const rows = [...SA.community['자유'], ...SA.community['공략']]
|
||||
.sort((a, b) => (b.hot ? 1 : 0) - (a.hot ? 1 : 0))
|
||||
.slice(0, 6);
|
||||
return (
|
||||
<Panel title="커뮤니티" en="COMMUNITY" right={
|
||||
<button onClick={onAll} style={{ display: 'flex', alignItems: 'center', gap: 3, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)', padding: 0 }}>
|
||||
전체 보기 <Icon name="chevR" size={13} stroke="var(--t2)" />
|
||||
</button>
|
||||
} bodyPad="2px 14px 6px">
|
||||
{rows.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 2px', ...rowDiv(i, rows.length), cursor: 'pointer' }}>
|
||||
<span style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: '#f1f3f5', border: '1px solid var(--line)', color: '#5a5f66', minWidth: 32, textAlign: 'center' }}>{p.tag}</span>
|
||||
<span style={{ flex: 1, fontSize: 14, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</span>
|
||||
{p.hot && <span style={{ fontSize: 10, fontWeight: 800, color: '#b23b3b', fontFamily: CH }}>HOT</span>}
|
||||
<span style={{ fontFamily: CH, fontSize: 12, color: PT, fontWeight: 700, minWidth: 26, textAlign: 'right' }}>{p.cmt}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)', minWidth: 44, textAlign: 'right' }}>{p.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 내 전적 요약 ──
|
||||
function MeCard({ onPick }) {
|
||||
const SA = window.SA, me = SA.me;
|
||||
const [scope, setScope] = useStateH('통합');
|
||||
const rankNo = scope === '통합' ? me.rankNoTotal : me.rankNoSeason;
|
||||
const selRank = scope === '통합' ? me.rankTotal : me.rankSeason;
|
||||
return (
|
||||
<Panel title="내 전적" en="MY RECORD" bodyPad="14px 14px" right={<Seg tabs={['통합', '시즌']} value={scope} onChange={setScope} />}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
|
||||
<RankBadge rank={selRank} size={46} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 800, color: 'var(--ink)' }}>{me.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>{me.clan}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--t3)', fontWeight: 600 }}>{scope} 랭킹</span>
|
||||
<span style={{ fontFamily: CH, fontSize: 15, fontWeight: 700, color: PT }}>{SA.fmt(rankNo)}<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 500, marginLeft: 1 }}>위</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', marginTop: 13, border: '1px solid var(--line)', borderRadius: 4 }}>
|
||||
{[['승·패', `${me.wins}/${me.losses}`], ['승률', me.wr + '%'], ['킬뎃', SA.kdPct(me.kda) + '%']].map(([k, v], i) => (
|
||||
<div key={k} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderLeft: i ? '1px solid var(--line)' : 'none', background: i % 2 ? '#fff' : '#f7f8f9' }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, marginBottom: 3 }}>{k}</div>
|
||||
<div style={{ fontFamily: CH, fontWeight: 700, fontSize: 15.5, color: k === '승률' ? wrColor(me.wr) : 'var(--ink)' }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => onPick(me)} style={{ width: '100%', marginTop: 12, padding: '9px 0', border: `1px solid ${PT}`, cursor: 'pointer',
|
||||
background: '#fff', color: PT, fontWeight: 700, fontSize: 13, borderRadius: 4, fontFamily: "'Pretendard',sans-serif" }}>내 전적 자세히 보기 ▸</button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SA 랭킹 (5종 · 각 top10) ──
|
||||
const SA_CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜'];
|
||||
|
||||
function RankRow({ i, onClick, badge, name, tag, sub, value, unit }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', border: 'none', background: i % 2 ? '#fff' : '#f7f8f9', padding: '8px 14px', cursor: onClick ? 'pointer' : 'default', textAlign: 'left', borderBottom: '1px solid #eaecef', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<span style={{ width: 16, textAlign: 'center', fontFamily: CH, fontWeight: 700, fontSize: 13.5, color: i < 3 ? PT : 'var(--t3)', flexShrink: 0 }}>{i + 1}</span>
|
||||
{badge}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--ink)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: '60%' }}>{name}</span>
|
||||
{tag && <span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{tag}</span>}
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 10.5, color: 'var(--t3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{sub}</div>}
|
||||
</div>
|
||||
<span style={{ fontFamily: CH, fontWeight: 700, fontSize: 13, color: 'var(--ink)', flexShrink: 0, whiteSpace: 'nowrap' }}>{value}{unit && <span style={{ fontSize: 10, color: 'var(--t3)', fontWeight: 500, marginLeft: 2 }}>{unit}</span>}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 즐겨찾기 (페이지네이션) ──
|
||||
function Favorites({ onPick }) {
|
||||
const SA = window.SA;
|
||||
const all = SA.favorites;
|
||||
const PER = 5;
|
||||
const pages = Math.ceil(all.length / PER);
|
||||
const [page, setPage] = useStateH(0);
|
||||
const start = page * PER;
|
||||
const view = all.slice(start, start + PER);
|
||||
return (
|
||||
<Panel title="즐겨찾기" en="FAVORITES" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>{all.length}명</span>} bodyPad="0">
|
||||
<div>
|
||||
{view.map((f, i) => (
|
||||
<button key={f.name} onClick={() => onPick(f)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', border: 'none', background: (start + i) % 2 ? '#fff' : '#f7f8f9', padding: '9px 14px', cursor: 'pointer', textAlign: 'left', borderBottom: '1px solid #eaecef', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<RankBadge rank={f.rank} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--ink)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: '60%' }}>{f.name}</span>
|
||||
{f.clan && <span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.clan}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', marginTop: 1 }}>{f.rank.name} · 승률 {f.wr}%</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: CH, fontWeight: 700, fontSize: 12.5, color: PT, flexShrink: 0 }}>킬뎃 {SA.kdPct(f.kda)}%</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0' }}>
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} style={pgBtn(page === 0)}>‹</button>
|
||||
{Array.from({ length: pages }).map((_, i) => (
|
||||
<button key={i} onClick={() => setPage(i)} style={{ width: 26, height: 26, borderRadius: 4, border: '1px solid ' + (i === page ? PT : 'var(--line2)'), background: i === page ? PT : '#fff', color: i === page ? '#fff' : 'var(--t2)', cursor: 'pointer', fontFamily: CH, fontWeight: 700, fontSize: 12 }}>{i + 1}</button>
|
||||
))}
|
||||
<button onClick={() => setPage(Math.min(pages - 1, page + 1))} disabled={page === pages - 1} style={pgBtn(page === pages - 1)}>›</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
function pgBtn(disabled) {
|
||||
return { width: 26, height: 26, borderRadius: 4, border: '1px solid var(--line2)', background: '#fff', color: disabled ? 'var(--line2)' : 'var(--t2)', cursor: disabled ? 'default' : 'pointer', fontFamily: "'Chakra Petch',sans-serif", fontWeight: 700, fontSize: 14 };
|
||||
}
|
||||
|
||||
function SARanking({ onPick, onClan }) {
|
||||
const SA = window.SA;
|
||||
const [cat, setCat] = useStateH('통합 계급');
|
||||
const [mode, setMode] = useStateH('솔로');
|
||||
const tcol = Object.fromEntries(SA.tiers.map((t) => [t.name, t.color]));
|
||||
const isClass = cat === '시즌 계급' || cat === '통합 계급';
|
||||
const isRanked = cat === '랭크전';
|
||||
const isRankedUser = isRanked && mode !== '클랜';
|
||||
const isRankedClan = isRanked && mode === '클랜';
|
||||
const isClan = cat === '공식 클랜' || cat === '클랜';
|
||||
const classList = cat === '시즌 계급' ? SA.seasonClassRanking : SA.totalClassRanking;
|
||||
const contentList = mode === '솔로' ? SA.soloRpRanking : SA.partyRpRanking;
|
||||
const clanList = cat === '공식 클랜' ? SA.officialClanRanking : SA.generalClanRanking;
|
||||
|
||||
return (
|
||||
<Panel title="SA 랭킹" en="RANKING" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>TOP 10</span>} bodyPad="11px 0 0">
|
||||
{/* 카테고리 선택 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, padding: '0 14px 10px' }}>
|
||||
{SA_CATS.map((c) => {
|
||||
const on = c === cat;
|
||||
return (
|
||||
<button key={c} onClick={() => setCat(c)} style={{ padding: '5px 10px', borderRadius: 4, border: `1px solid ${on ? PT : 'var(--line2)'}`, cursor: 'pointer',
|
||||
fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5, background: on ? PT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{c}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 랭크전 하위 토글 */}
|
||||
{isRanked && (
|
||||
<div style={{ padding: '0 14px 11px' }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--line2)', borderRadius: 4, overflow: 'hidden', background: '#fff' }}>
|
||||
{['솔로', '파티', '클랜'].map((t, i) => {
|
||||
const on = mode === t;
|
||||
return (
|
||||
<button key={t} onClick={() => setMode(t)} style={{ flex: 1, padding: '6px 0', border: 'none', borderLeft: i ? '1px solid var(--line2)' : 'none', cursor: 'pointer',
|
||||
fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? PT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계급 랭킹 */}
|
||||
{isClass && classList.map((e, i) => {
|
||||
const p = SA.allPlayers.find((x) => x.name === e.name);
|
||||
return (
|
||||
<RankRow key={e.name} i={i} onClick={() => p && onPick(p)}
|
||||
badge={p ? <RankBadge rank={p.rank} size={28} /> : null}
|
||||
tag={p && p.clan} name={e.name} sub={p && p.rank.name}
|
||||
value={SA.fmt(e.score)} unit="점" />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 랭크전(솔로/파티) 랭킹 */}
|
||||
{isRankedUser && contentList.map((e, i) => {
|
||||
const p = SA.allPlayers.find((x) => x.name === e.name);
|
||||
const col = tcol[e.tier] || PT;
|
||||
return (
|
||||
<RankRow key={e.name} i={i} onClick={() => p && onPick(p)}
|
||||
badge={<div style={{ width: 28, height: 28, borderRadius: 4, background: col, display: 'grid', placeItems: 'center', color: '#fff', fontWeight: 800, fontSize: 12.5, flexShrink: 0 }}>{e.tier[0]}</div>}
|
||||
tag={p && p.clan} name={e.name} sub={<span style={{ color: col, fontWeight: 600 }}>{e.tier}</span>}
|
||||
value={SA.fmt(e.rp)} unit="RP" />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 랭크전(클랜) 랭킹 — 클랜 정보 */}
|
||||
{isRankedClan && SA.clanRpRanking.map((c, i) => (
|
||||
<RankRow key={c.name} i={i} onClick={() => onClan && onClan(c.name)}
|
||||
badge={<div style={{ width: 28, height: 28, borderRadius: 4, background: '#23262b', display: 'grid', placeItems: 'center', fontFamily: CH, fontWeight: 700, fontSize: 10, color: '#fff', flexShrink: 0 }}>{c.tag}</div>}
|
||||
name={c.name} sub={`${c.members}명`} value={SA.fmt(c.rp)} unit="RP" />
|
||||
))}
|
||||
|
||||
{/* 클랜 랭킹 */}
|
||||
{isClan && clanList.map((c, i) => (
|
||||
<RankRow key={c.name} i={i} onClick={() => onClan && onClan(c.name)}
|
||||
badge={<div style={{ width: 28, height: 28, borderRadius: 4, background: '#23262b', display: 'grid', placeItems: 'center', fontFamily: CH, fontWeight: 700, fontSize: 10, color: '#fff', flexShrink: 0 }}>{c.tag}</div>}
|
||||
name={<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>{c.name}{cat === '공식 클랜' && <span style={{ fontSize: 9, fontWeight: 700, color: PT, border: `1px solid ${PT}`, borderRadius: 3, padding: '0 4px', lineHeight: 1.5 }}>인증</span>}</span>}
|
||||
sub={`${c.members}명`} value={SA.fmt(c.rp)} unit="RP" />
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 인기 검색 랭킹(가장 많이 조회된 유저) ──
|
||||
function SearchRank({ onPick }) {
|
||||
const SA = window.SA;
|
||||
const trend = (m) => {
|
||||
if (m === 'new') return <span style={{ fontFamily: CH, fontSize: 9.5, fontWeight: 700, color: '#b23b3b', width: 28, textAlign: 'center', flexShrink: 0 }}>NEW</span>;
|
||||
if (m === 0) return <span style={{ fontSize: 11, color: 'var(--t3)', width: 28, textAlign: 'center', flexShrink: 0 }}>–</span>;
|
||||
const up = m > 0;
|
||||
return <span style={{ fontFamily: CH, fontSize: 11, fontWeight: 700, color: up ? PT : '#e5484d', width: 28, textAlign: 'center', flexShrink: 0 }}>{up ? '▲' : '▼'}{Math.abs(m)}</span>;
|
||||
};
|
||||
return (
|
||||
<Panel title="인기 검색" en="TRENDING" right={<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>최근 24시간</span>} bodyPad="4px 0 0">
|
||||
{SA.searchTrending.map((s, i) => {
|
||||
const p = SA.allPlayers.find((x) => x.name === s.name);
|
||||
return (
|
||||
<button key={s.name} onClick={() => p && onPick(p)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', border: 'none', background: i % 2 ? '#fff' : '#f7f8f9', padding: '8px 14px', cursor: p ? 'pointer' : 'default', textAlign: 'left', borderBottom: '1px solid #eaecef', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<span style={{ width: 16, textAlign: 'center', fontFamily: CH, fontWeight: 700, fontSize: 13.5, color: i < 3 ? PT : 'var(--t3)' }}>{i + 1}</span>
|
||||
{trend(s.move)}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--ink)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: '60%' }}>{s.name}</span>
|
||||
{p && p.clan && <span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.clan}</span>}
|
||||
</div>
|
||||
<span style={{ fontFamily: CH, fontSize: 12, color: 'var(--t2)', fontWeight: 600, flexShrink: 0 }}>{SA.fmt(s.count)}<span style={{ fontSize: 10, color: 'var(--t3)', fontWeight: 500 }}> 조회</span></span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 시즌 현황 ──
|
||||
function SeasonCard() {
|
||||
const SA = window.SA, s = SA.season;
|
||||
const [mapTab, setMapTab] = useStateH('5vs5');
|
||||
const maps = mapTab === '5vs5' ? s.maps5v5 : s.maps3v3;
|
||||
return (
|
||||
<Panel title="시즌 현황" en="SEASON" right={<span style={{ fontFamily: CH, fontSize: 12.5, fontWeight: 700, color: PT }}>{s.dday}</span>} bodyPad="14px 14px">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, marginBottom: 13 }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 6, background: '#23262b', display: 'grid', placeItems: 'center', color: '#fff', fontFamily: CH, fontWeight: 700, fontSize: 14, flexShrink: 0 }}>S2</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--ink)', lineHeight: 1.25 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)', marginTop: 2 }}>{s.period} 종료</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 7, background: '#d4d8de', borderRadius: 4, overflow: 'hidden', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.05)' }}>
|
||||
<div style={{ width: s.progress + '%', height: '100%', background: PT, borderRadius: 4 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--t3)', marginTop: 5 }}>
|
||||
<span>시즌 진행</span><span style={{ fontFamily: CH, color: 'var(--t2)', fontWeight: 600 }}>{s.progress}%</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 7 }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600 }}>시즌 맵</div>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--line2)', borderRadius: 4, overflow: 'hidden', background: '#fff' }}>
|
||||
{['5vs5', '3vs3'].map((t, i) => {
|
||||
const on = mapTab === t;
|
||||
return (
|
||||
<button key={t} onClick={() => setMapTab(t)} style={{ padding: '3px 10px', border: 'none', borderLeft: i ? '1px solid var(--line2)' : 'none', cursor: 'pointer',
|
||||
fontFamily: CH, fontWeight: 700, fontSize: 10.5, background: on ? PT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{maps.map((m) => <span key={m} style={{ padding: '4px 10px', borderRadius: 4, background: '#fff', border: '1px solid var(--line2)', fontSize: 11.5, color: 'var(--t2)', fontWeight: 500 }}>{m}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 폼 좋은 유저 (연승 / 승률 / 킬뎃 탭) ──
|
||||
function HotStreak({ onPick }) {
|
||||
const SA = window.SA;
|
||||
const [tab, setTab] = useStateH('연승');
|
||||
let rows;
|
||||
if (tab === '연승') {
|
||||
rows = SA.hotStreak.map((e) => ({ p: SA.allPlayers.find((x) => x.name === e.name), streak: e.streak }));
|
||||
} else if (tab === '승률') {
|
||||
rows = [...SA.allPlayers].sort((a, b) => b.wr - a.wr).slice(0, 6).map((p) => ({ p }));
|
||||
} else {
|
||||
rows = [...SA.allPlayers].sort((a, b) => b.kda - a.kda).slice(0, 6).map((p) => ({ p }));
|
||||
}
|
||||
const metric = (p, streak) => {
|
||||
if (tab === '연승') return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: '#e5484d' }}>
|
||||
<Icon name="flame" size={14} stroke="#e5484d" />
|
||||
<span style={{ fontFamily: CH, fontSize: 14, fontWeight: 700 }}>{streak}</span><span style={{ fontSize: 10.5, fontWeight: 700 }}>연승</span>
|
||||
</span>
|
||||
);
|
||||
if (tab === '승률') return <span style={{ fontFamily: CH, fontSize: 15, fontWeight: 700, color: wrColor(p.wr) }}>{p.wr}%</span>;
|
||||
return <span style={{ fontFamily: CH, fontSize: 15, fontWeight: 700, color: PT }}>{SA.kdPct(p.kda)}%</span>;
|
||||
};
|
||||
return (
|
||||
<Panel title="폼 좋은 유저" en="HOT STREAK" right={<Seg tabs={['연승', '승률', '킬뎃']} value={tab} onChange={setTab} />} bodyPad="12px 14px 14px">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }}>
|
||||
{rows.map(({ p, streak }, i) => p && (
|
||||
<button key={p.name} onClick={() => onPick(p)} style={{ display: 'flex', alignItems: 'center', gap: 10, border: '1px solid var(--line2)', borderRadius: 5, background: '#fff', padding: '10px 12px', cursor: 'pointer', textAlign: 'left', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<span style={{ width: 14, textAlign: 'center', fontFamily: CH, fontWeight: 700, fontSize: 12.5, color: i < 3 ? PT : 'var(--t3)', flexShrink: 0 }}>{i + 1}</span>
|
||||
<RankBadge rank={p.rank} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: '60%' }}>{p.name}</span>
|
||||
{p.clan && <span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.clan}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', marginTop: 1 }}>{p.rank.name}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{metric(p, streak)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 오늘의 전장 ──
|
||||
function LiveStats() {
|
||||
const SA = window.SA, L = SA.liveStats;
|
||||
return (
|
||||
<Panel title="오늘의 전장" en="TODAY" bodyPad="14px 14px">
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7, marginBottom: 12, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: CH, fontWeight: 700, fontSize: 26, color: 'var(--ink)', letterSpacing: '-.5px' }}>{SA.fmt(L.totalMatches)}</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--t2)' }}>매치</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: CH, fontSize: 12, color: PT, fontWeight: 700 }}>▲ {L.prevDelta}%</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid var(--line)' }}>
|
||||
{L.matchModes.map(({ name, count }) => (
|
||||
<div key={name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '8px 0', borderBottom: '1px solid #eaecef' }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--t2)' }}>{name}</span>
|
||||
<span style={{ fontFamily: CH, fontWeight: 600, fontSize: 13, color: 'var(--ink)', whiteSpace: 'nowrap' }}>{SA.fmt(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '8px 0' }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--t2)' }}>오늘 제재</span>
|
||||
<span style={{ fontFamily: CH, fontWeight: 600, fontSize: 13, color: '#b23b3b', whiteSpace: 'nowrap' }}>{L.bannedToday}명</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line2)', background: '#fff' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '24px 28px 34px', display: 'flex', alignItems: 'flex-start', gap: 20 }}>
|
||||
<Logo size={18} />
|
||||
<div style={{ display: 'flex', gap: 16, marginLeft: 8 }}>
|
||||
{['이용약관', '개인정보처리방침', '공지사항', '문의하기'].map((t) => (
|
||||
<a key={t} onClick={() => { if (t === '문의하기') window.dispatchEvent(new Event('sa-open-feedback')); }} style={{ fontSize: 12.5, color: 'var(--t2)', cursor: 'pointer' }}>{t}</a>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', textAlign: 'right', fontSize: 11.5, color: 'var(--t3)', lineHeight: 1.7 }}>
|
||||
searchgg는 서든어택 전적 조회를 위한 비공식 팬 사이트입니다. 본 페이지의 데이터는 데모용 예시입니다.<br />© 2026 searchgg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 모집 중인 클랜 (포스터) ──
|
||||
function RecruitPosters({ onPlaza, onOpenClan }) {
|
||||
let pool = window.ClanRecruits ? window.ClanRecruits() : [];
|
||||
try { if (localStorage.getItem('sa-my-recruit-active') === '0') pool = pool.filter((c) => c.name !== (window.SA.me.clan || '')); } catch (e) {}
|
||||
const list = pool.slice().sort((a, b) => b.hot - a.hot).slice(0, 4);
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<Panel title="모집 중인 클랜" en="RECRUIT" right={
|
||||
<button onClick={onPlaza} style={{ display: 'flex', alignItems: 'center', gap: 3, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)', padding: 0 }}>
|
||||
전체 보기 <Icon name="chevR" size={13} stroke="var(--t2)" />
|
||||
</button>
|
||||
} bodyPad="14px 14px">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
|
||||
{list.map((rc) => (
|
||||
<div key={rc.name} onClick={onPlaza} style={{ cursor: 'pointer' }}>
|
||||
<div style={{ aspectRatio: '3 / 4', border: '1px solid var(--line)', borderRadius: 5, overflow: 'hidden', background: '#f4f5f7' }}>
|
||||
<image-slot id={`recruit-poster-${rc.name}`} shape="rect" placeholder="홍보 포스터" style={{ display: 'block', width: '100%', height: '100%' }}></image-slot>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<window.Emblem cfg={rc.emblem} size={20} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{rc.name}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 10.5, color: 'var(--t3)', paddingLeft: 27 }}>랭킹 {rc.rank}위 · {rc.openings}명 모집</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Home() {
|
||||
const [sel, setSel] = useStateH(null);
|
||||
const [login, setLogin] = useStateH(false);
|
||||
const [clan, setClan] = useStateH(null);
|
||||
const [plaza, setPlaza] = useStateH(false);
|
||||
const [community, setCommunity] = useStateH(false);
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg2)', color: 'var(--ink)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<Nav active="홈" onLogin={() => setLogin(true)} onClan={() => setPlaza(true)} onCommunity={() => setCommunity(true)} />
|
||||
<Hero onPick={setSel} />
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '20px 28px 40px' }}>
|
||||
<EventStrip />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 348px', gap: 18, alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<StreamLive onPick={setSel} />
|
||||
<HotStreak onPick={setSel} />
|
||||
<RecruitPosters onPlaza={() => setPlaza(true)} onOpenClan={(n) => setClan(n)} />
|
||||
<SANews />
|
||||
<Community onAll={() => setCommunity(true)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<MeCard onPick={setSel} />
|
||||
<Favorites onPick={setSel} />
|
||||
<SeasonCard />
|
||||
<SARanking onPick={setSel} onClan={setClan} />
|
||||
<SearchRank onPick={setSel} />
|
||||
<LiveStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
{sel && <ProfileScreen player={sel} onClose={() => setSel(null)} onPick={setSel} onLogin={() => setLogin(true)} onClan={(n) => { setSel(null); setClan(n); }} onCommunity={() => { setSel(null); setCommunity(true); }} />}
|
||||
{plaza && <ClanPlaza onClose={() => setPlaza(false)} onOpenClan={(n) => setClan(n)} onLogin={() => setLogin(true)} onClan={() => setPlaza(true)} onCommunity={() => { setPlaza(false); setCommunity(true); }} />}
|
||||
{community && <CommunityScreen onClose={() => setCommunity(false)} onLogin={() => setLogin(true)} onClan={() => { setCommunity(false); setPlaza(true); }} />}
|
||||
{clan && <ClanHome clanName={clan} onClose={() => setClan(null)} onPick={(n) => { setClan(null); setSel(window.SA.allPlayers.find((p) => p.name === n) || window.SA.me); }} onLogin={() => setLogin(true)} onClan={() => { setClan(null); setPlaza(true); }} onCommunity={() => { setClan(null); setPlaza(false); setCommunity(true); }} />}
|
||||
{login && <LoginScreen onClose={() => setLogin(false)} />}
|
||||
<FeedbackWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Home, TopBar, Nav, Footer, Panel, Seg, PT, CH });
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 로그인 화면 (소셜 로그인: 카카오 / 디스코드 / 구글)
|
||||
// window.LoginScreen
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function KakaoMark({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3.5c-5 0-9 3.13-9 7 0 2.5 1.7 4.69 4.27 5.94-.15.53-.78 2.78-.81 2.96 0 0-.02.14.07.2.09.05.2 0 .2 0 .26-.04 3.02-2 3.5-2.33.57.08 1.16.13 1.77.13 5 0 9-3.13 9-7s-4-7-9-7z" fill="#191600" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function DiscordMark({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 15.885 3c-.21.375-.45.88-.617 1.28a18.27 18.27 0 0 0-5.535 0C9.565 3.88 9.318 3.375 9.107 3A19.736 19.736 0 0 0 4.677 4.369C1.9 8.46 1.144 12.44 1.522 16.36a19.9 19.9 0 0 0 6.075 3.05c.49-.665.927-1.372 1.304-2.114-.717-.27-1.404-.604-2.05-.99.172-.126.34-.257.502-.39 3.96 1.84 8.245 1.84 12.16 0 .164.135.332.266.5.39-.647.387-1.335.72-2.052.99.378.742.814 1.45 1.304 2.114a19.84 19.84 0 0 0 6.078-3.05c.444-4.55-.758-8.494-3.205-11.99zM8.02 14.09c-1.18 0-2.15-1.085-2.15-2.42 0-1.334.95-2.42 2.15-2.42 1.21 0 2.17 1.096 2.15 2.42 0 1.335-.95 2.42-2.15 2.42zm7.96 0c-1.18 0-2.15-1.085-2.15-2.42 0-1.334.95-2.42 2.15-2.42 1.21 0 2.17 1.096 2.15 2.42 0 1.335-.94 2.42-2.15 2.42z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function GoogleMark({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<path d="M23.04 12.26c0-.81-.07-1.59-.21-2.34H12v4.43h6.2a5.3 5.3 0 0 1-2.3 3.48v2.89h3.72c2.18-2 3.42-4.96 3.42-8.46z" fill="#4285F4" />
|
||||
<path d="M12 24c3.11 0 5.72-1.03 7.63-2.79l-3.72-2.89c-1.03.69-2.35 1.1-3.91 1.1-3 0-5.55-2.03-6.46-4.76H1.69v2.98A11.99 11.99 0 0 0 12 24z" fill="#34A853" />
|
||||
<path d="M5.54 14.66A7.2 7.2 0 0 1 5.16 12c0-.92.16-1.82.38-2.66V6.36H1.69A11.99 11.99 0 0 0 .43 12c0 1.94.47 3.77 1.26 5.4l3.85-2.74z" fill="#FBBC05" />
|
||||
<path d="M12 4.77c1.69 0 3.21.58 4.4 1.72l3.3-3.3C17.71 1.19 15.1 0 12 0 7.31 0 3.26 2.69 1.69 6.36l3.85 2.98C6.45 6.61 9 4.77 12 4.77z" fill="#EA4335" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialButton({ provider, bg, color, border, mark }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '13px 18px', borderRadius: 8, cursor: 'pointer',
|
||||
background: bg, color, border: border || 'none', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 14.5,
|
||||
boxShadow: hover ? '0 4px 14px rgba(0,0,0,.14)' : '0 1px 2px rgba(0,0,0,.06)', transform: hover ? 'translateY(-1px)' : 'none', transition: 'all .16s' }}>
|
||||
<span style={{ display: 'grid', placeItems: 'center', width: 22 }}>{mark}</span>
|
||||
<span style={{ flex: 1, textAlign: 'center', marginRight: 22 }}>{provider}(으)로 계속하기</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginScreen({ onClose }) {
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 400, background: 'rgba(20,22,26,.62)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'sgFade .2s ease' }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 420, maxWidth: '100%', background: '#fff', borderRadius: 14, overflow: 'hidden', boxShadow: '0 24px 70px rgba(0,0,0,.34)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<div style={{ position: 'relative', background: '#23262b', padding: '34px 30px 30px', textAlign: 'center' }}>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ position: 'absolute', top: 14, right: 14, width: 30, height: 30, borderRadius: 6, border: 'none', background: 'rgba(255,255,255,.12)', color: '#fff', cursor: 'pointer', fontSize: 16, lineHeight: 1 }}>×</button>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 14 }}><Logo size={26} light /></div>
|
||||
<h2 style={{ margin: 0, color: '#fff', fontSize: 21, fontWeight: 800, letterSpacing: '-.4px' }}>로그인</h2>
|
||||
<p style={{ margin: '7px 0 0', color: 'rgba(255,255,255,.6)', fontSize: 13, lineHeight: 1.5 }}>소셜 계정으로 간편하게 시작하세요</p>
|
||||
</div>
|
||||
<div style={{ padding: '24px 30px 14px', display: 'flex', flexDirection: 'column', gap: 11 }}>
|
||||
<SocialButton provider="카카오" bg="#FEE500" color="#191600" mark={<KakaoMark />} />
|
||||
<SocialButton provider="디스코드" bg="#5865F2" color="#fff" mark={<DiscordMark />} />
|
||||
<SocialButton provider="구글" bg="#fff" color="#33373d" border="1px solid #dadce0" mark={<GoogleMark />} />
|
||||
</div>
|
||||
<div style={{ padding: '8px 30px 26px', textAlign: 'center' }}>
|
||||
<p style={{ margin: 0, fontSize: 11.5, color: '#949aa3', lineHeight: 1.6 }}>
|
||||
로그인 시 <a style={{ color: '#5b626c', textDecoration: 'underline', cursor: 'pointer' }}>이용약관</a> 및 <a style={{ color: '#5b626c', textDecoration: 'underline', cursor: 'pointer' }}>개인정보처리방침</a>에<br />동의하는 것으로 간주됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { LoginScreen });
|
||||
@@ -0,0 +1,526 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 전적검색(프로필) 섹션 컴포넌트
|
||||
// 요약 헤더 / 시즌별 랭크전 / 클랜전 / 맵별 / 최근 동향 / 매치 기록 / 자주 함께한 유저
|
||||
// 전역(window): Panel, Seg, RankBadge, WinRing, Icon, KdaChip, wrColor, SA
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const PSCH = "'Chakra Petch',sans-serif";
|
||||
const PSPT = '#48515f';
|
||||
const psTh = (a) => ({ fontSize: 11, fontWeight: 700, color: 'var(--t3)', padding: '10px 14px', textAlign: a, whiteSpace: 'nowrap', letterSpacing: '.2px' });
|
||||
const psTd = { fontSize: 13, color: '#33373d', padding: '11px 14px', whiteSpace: 'nowrap', verticalAlign: 'middle' };
|
||||
const psSmall = { fontSize: 11, color: 'var(--t3)', fontWeight: 600 };
|
||||
|
||||
function tierColor(name) { const t = (window.SA.tiers || []).find((x) => x.name === name); return t ? t.color : PSPT; }
|
||||
function recStr(d) { return `${d.matches}전 ${d.w}승 ${d.l}패`; }
|
||||
|
||||
function WrCell({ wr, w = 46 }) {
|
||||
const fill = Math.max(0.04, Math.min(1, (wr - 35) / (80 - 35)));
|
||||
const col = wrColor(wr);
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<span style={{ width: w, height: 7, background: '#d4d8de', borderRadius: 4, overflow: 'hidden', display: 'inline-block', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.05)' }}>
|
||||
<span style={{ display: 'block', width: `${fill * 100}%`, height: '100%', background: col }} />
|
||||
</span>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 700, color: col, minWidth: 36, textAlign: 'right' }}>{wr}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierChipS({ name, div }) {
|
||||
const col = tierColor(name);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 2, background: col, transform: 'rotate(45deg)', flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 700, color: 'var(--ink)' }}>{name}{div ? ' ' + div : ''}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 요약 헤더 ──
|
||||
function ProfileSummary({ p }) {
|
||||
const SA = window.SA;
|
||||
const accent = p.rank.color;
|
||||
const [scope, setScope] = React.useState('통합');
|
||||
const selRank = scope === '통합' ? p.rankTotal : p.rankSeason;
|
||||
const selRankNo = scope === '통합' ? p.rankNoTotal : p.rankNoSeason;
|
||||
const stats = [
|
||||
['승률', p.wr + '%', wrColor(p.wr)],
|
||||
['킬뎃', SA.kdPct(p.kda) + '%', PSPT],
|
||||
['헤드샷', p.hs + '%', 'var(--ink)'],
|
||||
['라이플', p.rifle + '%', 'var(--ink)'],
|
||||
['스나', p.sniper + '%', 'var(--ink)'],
|
||||
['특수', p.special + '%', 'var(--ink)'],
|
||||
['랭킹', SA.fmt(selRankNo) + '위', PSPT],
|
||||
['매너', p.manner + '점', 'var(--ink)'],
|
||||
['접속률', p.connRate + '%', 'var(--ink)'],
|
||||
];
|
||||
return (
|
||||
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 5, overflow: 'hidden', marginBottom: 18 }}>
|
||||
<div style={{ height: 4, background: accent }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '22px 26px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center', flexShrink: 0 }}>
|
||||
<Seg tabs={['통합', '시즌']} value={scope} onChange={setScope} />
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}><RankBadge rank={selRank} size={66} /></div>
|
||||
<div style={{ fontSize: 12, color: selRank.color, fontWeight: 700, marginTop: 5 }}>{selRank.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--t3)', marginTop: 1 }}>{scope} 계급</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.5px' }}>{p.name}</h1>
|
||||
{p.clan && <span style={{ fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 14, color: 'var(--t2)' }}>{p.clan}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 13, display: 'flex', gap: 8 }}>
|
||||
<button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 5, border: 'none', background: '#20242b', color: '#fff', fontWeight: 700, fontSize: 12.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<Icon name="heart" size={14} stroke="#fff" /> 즐겨찾기
|
||||
</button>
|
||||
<button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 5, cursor: 'pointer', background: '#fff', border: '1px solid var(--line2)', color: '#3a4049', fontWeight: 600, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<Icon name="refresh" size={14} stroke="#3a4049" /> 전적 갱신
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, flexShrink: 0 }}>
|
||||
<WinRing wr={p.wr} size={88} sw={8} />
|
||||
<WinRing wr={SA.kdPct(p.kda)} size={88} sw={8} label="킬뎃" color={PSPT} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(9,1fr)', borderTop: '1px solid var(--line)' }}>
|
||||
{stats.map(([lab, val, col], i) => (
|
||||
<div key={lab} style={{ padding: '12px 6px', textAlign: 'center', borderLeft: i ? '1px solid var(--line)' : 'none', background: i % 2 ? '#fff' : '#fafbfc' }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, marginBottom: 5 }}>{lab}</div>
|
||||
<div style={{ fontFamily: PSCH, fontWeight: 700, fontSize: 14.5, color: col }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 시즌별 랭크전 전적 (시즌 선택 · 솔로/파티) ──
|
||||
function RankedSeasons({ p }) {
|
||||
const seasons = p.rankedSeasons;
|
||||
const [sel, setSel] = React.useState(seasons[0].season);
|
||||
const isAll = sel === '통산';
|
||||
const cur = seasons.find((s) => s.season === sel) || seasons[0];
|
||||
const agg = (key) => {
|
||||
let M = 0, W = 0, L = 0, kdW = 0, hsW = 0;
|
||||
seasons.forEach((s) => { const d = s[key]; M += d.matches; W += d.w; L += d.l; kdW += d.kd * d.matches; hsW += d.hs * d.matches; });
|
||||
return { matches: M, w: W, l: L, wr: M ? Math.round(W / M * 100) : 0, kd: M ? Math.round(kdW / M) : 0, hs: M ? Math.round(hsW / M) : 0, tier: null };
|
||||
};
|
||||
const rows = [['솔로', isAll ? agg('solo') : cur.solo], ['파티', isAll ? agg('party') : cur.party]];
|
||||
const sel2 = (
|
||||
<select value={sel} onChange={(e) => setSel(e.target.value)} style={{ fontFamily: "'Pretendard',sans-serif", fontSize: 12, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', borderRadius: 4, padding: '5px 8px', background: '#fff', cursor: 'pointer' }}>
|
||||
<option value="통산">통산</option>
|
||||
{seasons.map((s) => <option key={s.season} value={s.season}>{s.season}</option>)}
|
||||
</select>
|
||||
);
|
||||
return (
|
||||
<Panel title="시즌별 랭크전 전적" en="RANKED" right={sel2} bodyPad="0">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 660 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line2)', background: '#f7f8f9' }}>
|
||||
<th style={psTh('left')}>구분</th><th style={{ ...psTh('left'), minWidth: 150 }}>티어</th><th style={psTh('right')}>티어 점수</th>
|
||||
<th style={psTh('right')}>매치</th><th style={psTh('right')}>전적</th>
|
||||
<th style={psTh('right')}>승률</th><th style={psTh('right')}>킬뎃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, d], i) => (
|
||||
<tr key={label} style={{ borderBottom: i === 0 ? '1px solid #eaecef' : 'none', background: i % 2 ? '#fff' : '#fbfbfc' }}>
|
||||
<td style={{ ...psTd, fontWeight: 700, color: 'var(--ink)' }}>{label} 랭크전</td>
|
||||
<td style={psTd}>{isAll ? <span style={{ color: 'var(--t3)' }}>—</span> : <TierChipS name={d.tier} div={d.div} />}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH, fontWeight: 600 }}>{isAll ? <span style={{ color: 'var(--t3)' }}>—</span> : <span>{window.SA.fmt(d.points)}<span style={{ fontSize: 10, color: 'var(--t3)', fontWeight: 500 }}> 점</span></span>}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH }}>{d.matches}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', color: 'var(--t2)' }}>{recStr(d)}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right' }}><WrCell wr={d.wr} /></td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH, fontWeight: 600 }}>{d.kd}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 클랜전 전적 (소속 클랜별) ──
|
||||
function ClanRecords({ p }) {
|
||||
const [open, setOpen] = React.useState(-1);
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
const all = p.clanRecords;
|
||||
const CAP = 4;
|
||||
const list = showAll ? all : all.slice(0, CAP);
|
||||
return (
|
||||
<Panel title="클랜전 전적" en="CLAN" right={<span style={psSmall}>소속 클랜별 · 클릭 시 활동 기간</span>} bodyPad="0">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 600 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line2)', background: '#f7f8f9' }}>
|
||||
<th style={psTh('left')}>클랜</th>
|
||||
<th style={psTh('right')}>매치</th><th style={psTh('right')}>전적</th>
|
||||
<th style={psTh('right')}>승률</th><th style={psTh('right')}>킬뎃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((c, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<React.Fragment key={c.clan + i}>
|
||||
<tr onClick={() => setOpen(isOpen ? -1 : i)} style={{ borderBottom: '1px solid #eaecef', background: isOpen ? '#f1f3f5' : (i % 2 ? '#fff' : '#fbfbfc'), cursor: 'pointer' }}>
|
||||
<td style={psTd}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9 }}>
|
||||
<Icon name="chevR" size={13} stroke="var(--t3)" style={{ transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
<span style={{ width: 26, height: 26, borderRadius: 4, background: '#23262b', display: 'grid', placeItems: 'center', fontFamily: PSCH, fontWeight: 700, fontSize: 9.5, color: '#fff' }}>{c.tag}</span>
|
||||
<span style={{ fontWeight: 700, color: 'var(--ink)' }}>{c.clan}</span>
|
||||
{i === 0 && <span style={{ fontSize: 9.5, fontWeight: 700, color: PSPT, border: `1px solid ${PSPT}`, borderRadius: 3, padding: '0 5px' }}>현재</span>}
|
||||
<span style={{ fontSize: 10.5, color: 'var(--t3)' }}>가입 {c.periods.length}회</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH }}>{c.matches}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', color: 'var(--t2)' }}>{recStr(c)}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right' }}><WrCell wr={c.wr} /></td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH, fontWeight: 600 }}>{c.kd}%</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '13px 16px 15px', background: '#f7f8f9', borderBottom: '1px solid #eaecef' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', marginBottom: 9, letterSpacing: '.2px' }}>활동 기간 · {c.periods.length}회 가입</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{c.periods.map((pd, j) => (
|
||||
<div key={j} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', background: '#fff', border: '1px solid var(--line)', borderRadius: 5 }}>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 700, fontSize: 11.5, color: '#fff', background: PSPT, borderRadius: 3, padding: '1px 7px', flexShrink: 0 }}>{c.periods.length - j}차</span>
|
||||
<Icon name="clock" size={14} stroke="var(--t3)" />
|
||||
<span style={{ fontFamily: PSCH, fontSize: 13, color: 'var(--ink)', fontWeight: 700 }}>{pd.from} ~ {pd.to}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--t3)' }}>약 {pd.months}개월</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{all.length > CAP && (
|
||||
<button onClick={() => setShowAll(!showAll)} style={{ width: '100%', padding: '10px 0', border: 'none', borderTop: '1px solid var(--line)', background: '#fff', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5, color: 'var(--t2)' }}>
|
||||
{showAll ? '접기 ▲' : `소속했던 클랜 더보기 +${all.length - CAP} ▼`}
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 최근 동향 추세 그래프 (승률 / 킬뎃 시계열) ──
|
||||
function TrendChart({ p }) {
|
||||
const [period, setPeriod] = React.useState('주별');
|
||||
const data = period === '일별' ? p.trend.daily : p.trend.weekly;
|
||||
const W = 720, H = 250, padL = 38, padR = 16, padT = 18, padB = 34;
|
||||
const iw = W - padL - padR, ih = H - padT - padB;
|
||||
const n = data.length;
|
||||
const x = (i) => padL + (n === 1 ? iw / 2 : (i / (n - 1)) * iw);
|
||||
const y = (v) => padT + (1 - (v - 20) / (90 - 20)) * ih;
|
||||
const path = (key) => data.map((d, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(d[key]).toFixed(1)}`).join(' ');
|
||||
const area = (key) => `${path(key)} L${x(n - 1).toFixed(1)} ${(padT + ih).toFixed(1)} L${x(0).toFixed(1)} ${(padT + ih).toFixed(1)} Z`;
|
||||
const WR = '#2f6fed', KD = PSPT;
|
||||
const grid = [20, 40, 60, 80];
|
||||
const last = data[n - 1];
|
||||
const first = data[0];
|
||||
const delta = (key) => last[key] - first[key];
|
||||
const dchip = (label, key, col) => {
|
||||
const dv = delta(key);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ width: 11, height: 3, borderRadius: 2, background: col }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--t2)', fontWeight: 600 }}>{label}</span>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 700, fontSize: 13, color: col }}>{last[key]}%</span>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 700, fontSize: 11.5, color: dv >= 0 ? '#16a34a' : '#e5484d' }}>
|
||||
{dv >= 0 ? '▲' : '▼'}{Math.abs(dv)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const labelStep = Math.ceil(n / 8);
|
||||
return (
|
||||
<Panel title="최근 동향" en="TREND" right={<Seg tabs={['일별', '주별']} value={period} onChange={setPeriod} />} bodyPad="14px 16px 16px">
|
||||
<div style={{ display: 'flex', gap: 20, marginBottom: 10, paddingLeft: 4 }}>
|
||||
{dchip('승률', 'wr', WR)}
|
||||
{dchip('킬뎃', 'kd', KD)}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--t3)' }}>{period === '일별' ? '최근 14일' : '최근 10주'} 추이</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
<defs>
|
||||
<linearGradient id="wrGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={WR} stopOpacity="0.16" /><stop offset="100%" stopColor={WR} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{grid.map((g) => (
|
||||
<g key={g}>
|
||||
<line x1={padL} y1={y(g)} x2={W - padR} y2={y(g)} stroke="#eceef1" strokeWidth="1" />
|
||||
<text x={padL - 8} y={y(g) + 3.5} textAnchor="end" fontSize="10.5" fill="#949aa3" fontFamily={PSCH}>{g}</text>
|
||||
</g>
|
||||
))}
|
||||
{data.map((d, i) => (i % labelStep === 0 || i === n - 1) && (
|
||||
<text key={i} x={x(i)} y={H - 12} textAnchor="middle" fontSize="10.5" fill="#949aa3">{d.label}</text>
|
||||
))}
|
||||
<path d={area('wr')} fill="url(#wrGrad)" />
|
||||
<path d={path('wr')} fill="none" stroke={WR} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<path d={path('kd')} fill="none" stroke={KD} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" strokeDasharray="1 0" />
|
||||
{data.map((d, i) => (
|
||||
<g key={'p' + i}>
|
||||
<circle cx={x(i)} cy={y(d.wr)} r={i === n - 1 ? 4 : 2.6} fill="#fff" stroke={WR} strokeWidth="2" />
|
||||
<circle cx={x(i)} cy={y(d.kd)} r={i === n - 1 ? 4 : 2.6} fill="#fff" stroke={KD} strokeWidth="2" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 맵별 전적 ──
|
||||
function MapRecords({ p }) {
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
const all = p.mapRecords;
|
||||
const CAP = 8;
|
||||
const list = showAll ? all : all.slice(0, CAP);
|
||||
return (
|
||||
<Panel title="맵별 전적" en="BY MAP" right={<span style={psSmall}>매치 많은 순 · {all.length}개 맵</span>} bodyPad="0">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line2)', background: '#f7f8f9' }}>
|
||||
<th style={{ ...psTh('left'), width: 30 }}>#</th>
|
||||
<th style={psTh('left')}>맵</th>
|
||||
<th style={psTh('right')}>매치</th><th style={psTh('right')}>전적</th>
|
||||
<th style={psTh('right')}>승률</th><th style={psTh('right')}>킬뎃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((m, i) => (
|
||||
<tr key={m.name + i} style={{ borderBottom: i < list.length - 1 ? '1px solid #eaecef' : 'none', background: i % 2 ? '#fff' : '#fbfbfc' }}>
|
||||
<td style={{ ...psTd, textAlign: 'center', fontFamily: PSCH, fontWeight: 700, fontSize: 11.5, color: 'var(--t3)' }}>{i + 1}</td>
|
||||
<td style={{ ...psTd, fontWeight: 700, color: 'var(--ink)' }}>{m.name}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH }}>{m.matches}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', color: 'var(--t2)' }}>{recStr(m)}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right' }}><WrCell wr={m.wr} /></td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH, fontWeight: 600 }}>{m.kd}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{all.length > CAP && (
|
||||
<button onClick={() => setShowAll(!showAll)} style={{ width: '100%', padding: '10px 0', border: 'none', borderTop: '1px solid var(--line)', background: '#fff', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5, color: 'var(--t2)' }}>
|
||||
{showAll ? '접기 ▲' : `맵 전체 보기 +${all.length - CAP} ▼`}
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 매치 기록 ──
|
||||
function TeamTable({ result, color, players }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '8px 12px', background: `${color}12` }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 2, background: color }} />
|
||||
<span style={{ fontWeight: 800, fontSize: 12.5, color }}>{result}</span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600 }}>5명</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<th style={{ ...psTh('left'), padding: '7px 12px' }}>플레이어</th>
|
||||
<th style={{ ...psTh('right'), padding: '7px 8px' }}>K / D / A</th>
|
||||
<th style={{ ...psTh('right'), padding: '7px 8px' }}>킬뎃</th>
|
||||
<th style={{ ...psTh('right'), padding: '7px 8px' }}>헤드샷</th>
|
||||
<th style={{ ...psTh('right'), padding: '7px 12px' }}>데미지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.map((pl, j) => {
|
||||
const pct = Math.round(pl.k / (pl.k + pl.d) * 100);
|
||||
return (
|
||||
<tr key={j} style={{ borderBottom: j < players.length - 1 ? '1px solid #f1f2f4' : 'none', background: pl.isOwner ? `${color}0c` : '#fff' }}>
|
||||
<td style={{ padding: '7px 12px', maxWidth: 150 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<RankBadge rank={pl.rank} size={22} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: pl.isOwner ? 800 : 600, color: pl.isOwner ? color : 'var(--ink)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{pl.isOwner && <span style={{ fontSize: 9.5, fontWeight: 800, marginRight: 4, color }}>ME</span>}{pl.name}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'right', fontFamily: PSCH, fontSize: 12.5 }}>
|
||||
<span style={{ color: 'var(--ink)' }}>{pl.k}</span>
|
||||
<span style={{ color: 'var(--t3)', margin: '0 2px' }}>/</span>
|
||||
<span style={{ color: '#e5484d' }}>{pl.d}</span>
|
||||
<span style={{ color: 'var(--t3)', margin: '0 2px' }}>/</span>
|
||||
<span style={{ color: '#48515f' }}>{pl.a}</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'right', fontFamily: PSCH, fontSize: 12.5, fontWeight: 600, color: PSPT }}>{pct}%</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'right', fontFamily: PSCH, fontSize: 12.5, color: 'var(--t2)' }}>{pl.hs}%</td>
|
||||
<td style={{ padding: '7px 12px', textAlign: 'right', fontFamily: PSCH, fontSize: 12.5, color: 'var(--t2)' }}>{window.SA.fmt(pl.dmg)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ m, i, last }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const pct = Math.round(m.k / (m.k + m.d) * 100);
|
||||
const winTeam = m.winSide === 'blue' ? m.teams.blue : m.teams.red;
|
||||
const loseTeam = m.winSide === 'blue' ? m.teams.red : m.teams.blue;
|
||||
return (
|
||||
<div style={{ borderBottom: last ? 'none' : '1px solid #f1f2f4' }}>
|
||||
<div onClick={() => setOpen(!open)} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '11px 16px', cursor: 'pointer',
|
||||
borderLeft: `3px solid ${m.win ? PSPT : '#e5484d'}`, background: open ? '#f7f8f9' : 'transparent' }}>
|
||||
<div style={{ width: 40, flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 800, fontSize: 13, color: m.win ? PSPT : '#e5484d' }}>{m.win ? '승' : '패'}</span>
|
||||
</div>
|
||||
<div style={{ width: 132, minWidth: 0, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.map}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--t3)' }}>{m.mode}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<KdaChip k={m.k} d={m.d} a={m.a} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--t3)' }}>킬뎃 {pct}%</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{m.ago}</span>
|
||||
<Icon name="chevR" size={15} stroke="var(--t3)" style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: '14px 16px 16px', background: '#f7f8f9', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 6, overflow: 'hidden', background: '#fff' }}>
|
||||
<TeamTable result="승리" color={PSPT} players={winTeam} />
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 6, overflow: 'hidden', background: '#fff' }}>
|
||||
<TeamTable result="패배" color="#e5484d" players={loseTeam} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchHistory({ p }) {
|
||||
const TABS = ['전체', '클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트'];
|
||||
const [tab, setTab] = React.useState('전체');
|
||||
React.useEffect(() => setTab('전체'), [p.name]);
|
||||
const ms = tab === '전체' ? p.matches : p.matches.filter((m) => m.mode === tab);
|
||||
const w = ms.filter((m) => m.win).length;
|
||||
return (
|
||||
<Panel title="매치 기록" en="MATCHES" right={<span style={psSmall}>{ms.length ? `최근 ${ms.length}전 · ${w}승 ${ms.length - w}패 · 클릭 시 상세` : '클릭 시 상세'}</span>} bodyPad="0">
|
||||
{/* 모드 서브탭 (가로 스크롤) */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '11px 14px', borderBottom: '1px solid var(--line)', overflowX: 'auto', whiteSpace: 'nowrap' }}>
|
||||
{TABS.map((t) => {
|
||||
const on = tab === t;
|
||||
return (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ flexShrink: 0, padding: '6px 13px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12,
|
||||
border: `1px solid ${on ? PSPT : 'var(--line2)'}`, background: on ? PSPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ms.map((m, i) => (
|
||||
<MatchRow key={i} m={m} i={i} last={i === ms.length - 1} />
|
||||
))}
|
||||
{ms.length === 0 && (
|
||||
<div style={{ padding: '26px 0', textAlign: 'center', fontSize: 13, color: 'var(--t3)' }}>해당 모드의 최근 매치가 없어요.</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 최근 전적 및 동향 ──
|
||||
function RecentTrend({ p }) {
|
||||
const recent = p.matches.slice(0, 12);
|
||||
const wins = recent.filter((m) => m.win).length;
|
||||
const losses = recent.length - wins;
|
||||
const wr = Math.round(wins / recent.length * 100);
|
||||
const avgKd = Math.round(recent.reduce((a, m) => a + m.k / (m.k + m.d) * 100, 0) / recent.length);
|
||||
return (
|
||||
<Panel title="최근 전적 및 동향" en="RECENT" bodyPad="14px 16px 16px">
|
||||
<div style={{ display: 'flex', gap: 18, marginBottom: 14, justifyContent: 'center' }}>
|
||||
<WinRing wr={wr} size={72} sw={7} label="최근 승률" />
|
||||
<WinRing wr={avgKd} size={72} sw={7} label="평균 킬뎃" color={PSPT} />
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 13, color: 'var(--t2)', marginBottom: 12 }}>
|
||||
최근 {recent.length}전 <b style={{ color: PSPT }}>{wins}승</b> <b style={{ color: '#e5484d' }}>{losses}패</b>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{recent.slice().reverse().map((m, i) => (
|
||||
<span key={i} style={{ flex: 1, height: 20, borderRadius: 3, display: 'grid', placeItems: 'center', fontSize: 9.5, fontWeight: 800, fontFamily: PSCH,
|
||||
background: m.win ? 'rgba(72,81,95,.9)' : 'rgba(229,72,77,.9)', color: '#fff' }}>{m.win ? '승' : '패'}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)', marginTop: 7 }}>← 최신 경기 흐름</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 자주 함께한 유저 ──
|
||||
function Companions({ p, onPick }) {
|
||||
const SA = window.SA;
|
||||
return (
|
||||
<Panel title="자주 함께한 유저" en="WITH" bodyPad="6px 8px 8px">
|
||||
{SA.friends.slice(0, 4).map((f) => (
|
||||
<button key={f.name} onClick={() => onPick(f)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', border: 'none', background: 'transparent', padding: '8px 8px', borderRadius: 5, cursor: 'pointer', textAlign: 'left', fontFamily: "'Pretendard',sans-serif" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f6f8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
||||
<RankBadge rank={f.rank} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, flexShrink: 0, maxWidth: '58%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
{f.clan && <span style={{ fontSize: 10.5, color: 'var(--t3)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.clan}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--t3)', marginTop: 1 }}>
|
||||
함께 {f.together.games}전<span style={{ color: 'var(--line2)', margin: '0 5px' }}>·</span><b style={{ fontFamily: PSCH, color: PSPT }}>{f.together.w}승</b> <b style={{ fontFamily: PSCH, color: '#e5484d' }}>{f.together.l}패</b>
|
||||
<span style={{ color: 'var(--line2)', margin: '0 5px' }}>·</span>
|
||||
<span style={{ fontFamily: PSCH, fontWeight: 600, color: wrColor(Math.round(f.together.w / f.together.games * 100)) }}>승률 {Math.round(f.together.w / f.together.games * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="chevR" size={15} stroke="var(--t3)" />
|
||||
</button>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 모드별 전적 ──
|
||||
function ModeRecords({ p }) {
|
||||
return (
|
||||
<Panel title="모드별 전적" en="BY MODE" bodyPad="0">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line2)', background: '#f7f8f9' }}>
|
||||
<th style={psTh('left')}>모드</th>
|
||||
<th style={psTh('right')}>매치</th><th style={psTh('right')}>전적</th>
|
||||
<th style={psTh('right')}>승률</th><th style={psTh('right')}>킬뎃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.modeRecords.map((m, i) => (
|
||||
<tr key={m.name + i} style={{ borderBottom: i < p.modeRecords.length - 1 ? '1px solid #eaecef' : 'none', background: i % 2 ? '#fff' : '#fbfbfc' }}>
|
||||
<td style={{ ...psTd, fontWeight: 700, color: 'var(--ink)' }}>{m.name}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH }}>{m.matches}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right', color: 'var(--t2)' }}>{recStr(m)}</td>
|
||||
<td style={{ ...psTd, textAlign: 'right' }}><WrCell wr={m.wr} /></td>
|
||||
<td style={{ ...psTd, textAlign: 'right', fontFamily: PSCH, fontWeight: 600 }}>{m.kd}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ProfileSummary, RankedSeasons, ClanRecords, MapRecords, ModeRecords, MatchHistory, RecentTrend, Companions, TrendChart });
|
||||
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 전적검색(프로필) 화면 — 메인 헤더 유지 + 검색 서브헤더
|
||||
// 섹션 컴포넌트는 sa-profile-sections.jsx (window 전역)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileScreen({ player, onClose, onPick, onLogin, onClan, onCommunity }) {
|
||||
const SA = window.SA;
|
||||
const p = player;
|
||||
const [tab, setTab] = React.useState('전적');
|
||||
const tabs = ['전적', '통계'];
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg2)', display: 'flex', flexDirection: 'column', animation: 'sgFade .22s ease' }}>
|
||||
<Nav active="전적검색" onLogin={onLogin} onClan={() => onClan && onClan(p.clan || '불곰부대')} onCommunity={onCommunity} />
|
||||
{/* 검색 서브헤더 */}
|
||||
<div style={{ flexShrink: 0, background: '#fff', borderBottom: '1px solid var(--line2)' }}>
|
||||
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '11px 28px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ flex: 1, maxWidth: 520 }}>
|
||||
<SearchBar onPick={onPick} placeholder="다른 유저의 닉네임을 검색하세요" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ maxWidth: 1180, width: '100%', margin: '0 auto', padding: '24px 28px 44px' }}>
|
||||
<ProfileSummary p={p} />
|
||||
|
||||
{/* 서브탭 바 */}
|
||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--line2)', marginBottom: 18 }}>
|
||||
{tabs.map((t) => {
|
||||
const on = tab === t;
|
||||
return (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ position: 'relative', padding: '12px 22px', border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 14.5, color: on ? 'var(--ink)' : 'var(--t3)' }}>
|
||||
{t}
|
||||
{on && <span style={{ position: 'absolute', left: 12, right: 12, bottom: -1, height: 3, background: p.rank.color, borderRadius: 2 }} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === '전적' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 18, alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, minWidth: 0 }}>
|
||||
<RankedSeasons p={p} />
|
||||
<ClanRecords p={p} />
|
||||
<MatchHistory p={p} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<RecentTrend p={p} />
|
||||
<Companions p={p} onPick={onPick} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === '통계' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<TrendChart p={p} />
|
||||
<ModeRecords p={p} />
|
||||
<MapRecords p={p} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ProfileScreen });
|
||||
@@ -0,0 +1,338 @@
|
||||
/* eslint-disable */
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// searchgg · 공용 컴포넌트 (라이트 테마 · 흰 배경 / 검정 포인트)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
// ── 아이콘 ──
|
||||
const PATHS = {
|
||||
search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm6 12 4 4',
|
||||
chevR: 'M9 5l7 7-7 7',
|
||||
chevL: 'M15 5l-7 7 7 7',
|
||||
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0',
|
||||
users: 'M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 19a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 13a6.5 6.5 0 0 1 3.5 6',
|
||||
bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
|
||||
refresh: 'M20 11a8 8 0 1 0-.7 4.5M20 5v6h-6',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3.5 2',
|
||||
heart: 'M12 20s-7-4.3-7-9.5A3.5 3.5 0 0 1 12 7a3.5 3.5 0 0 1 7 3.5C19 15.7 12 20 12 20z',
|
||||
gun: 'M3 8h15l-1 4h-3l-1 3h-3l-1-3H5a2 2 0 0 1-2-2V8zM8 12v3',
|
||||
shieldcheck: 'M12 3l7 3v5c0 4.5-3 7.3-7 8.5-4-1.2-7-4-7-8.5V6l7-3zM9 11.5l2 2 4-4',
|
||||
flame: 'M12 2c1 3-1.5 4.2-1.5 6.5 0 1.4 1 2.3 1 2.3s.6-1 .6-2.2c1.6 1 3.4 3 3.4 5.6a5.5 5.5 0 1 1-11 0c0-2.3 1.4-4 2.2-5 .3 1 .9 1.6.9 1.6s-.2-2.3 1.2-4.2C10.4 4.6 12 4 12 2z',
|
||||
};
|
||||
function Icon({ name, size = 18, stroke = 'currentColor', fill = 'none', sw = 1.7, style }) {
|
||||
const filled = ['flame', 'bolt', 'heart'].includes(name);
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? stroke : fill}
|
||||
stroke={filled ? 'none' : stroke} strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, ...style }}>
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 로고 ──
|
||||
function Logo({ size = 24, light = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, userSelect: 'none' }}>
|
||||
<svg width={size * 1.16} height={size * 1.16} viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="9" fill="none" stroke="#48515f" strokeWidth="1.8" />
|
||||
<path d="M14 3.5v4M14 20.5v4M3.5 14h4M20.5 14h4" stroke="#48515f" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="14" cy="14" r="2.1" fill="#48515f" />
|
||||
</svg>
|
||||
<div style={{ fontFamily: "'Chakra Petch', sans-serif", fontWeight: 700, fontSize: size, letterSpacing: '-0.5px', lineHeight: 1 }}>
|
||||
<span style={{ color: light ? '#fff' : '#15171c' }}>search</span><span style={{ color: '#48515f' }}>.gg</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 계급(랭크) 뱃지 ──
|
||||
function RankBadge({ rank, size = 44, showName = false }) {
|
||||
if (!rank) return null;
|
||||
const c = rank.color;
|
||||
const pip = (i) => {
|
||||
const cx = size / 2, base = size * 0.66, gap = size * 0.13;
|
||||
const total = rank.pips, x = cx + (i - (total - 1) / 2) * gap;
|
||||
if (rank.shape === 'star') return <path key={i} d="M0 -2.1 L0.62 -0.65 L2.1 -0.5 L1 0.5 L1.35 2 L0 1.2 L-1.35 2 L-1 0.5 L-2.1 -0.5 L-0.62 -0.65 Z" transform={`translate(${x} ${base}) scale(${size * 0.06})`} fill={c} />;
|
||||
if (rank.shape === 'flower') return <circle key={i} cx={x} cy={base} r={size * 0.05} fill={c} />;
|
||||
if (rank.shape === 'dia') return <rect key={i} x={x - size * 0.045} y={base - size * 0.045} width={size * 0.09} height={size * 0.09} fill={c} transform={`rotate(45 ${x} ${base})`} />;
|
||||
if (rank.shape === 'chev') return <path key={i} d={`M ${x - size * 0.07} ${base + size * 0.05} L ${x} ${base - size * 0.03} L ${x + size * 0.07} ${base + size * 0.05}`} stroke={c} strokeWidth={size * 0.035} fill="none" />;
|
||||
return <rect key={i} x={x - size * 0.05} y={base - size * 0.025} width={size * 0.1} height={size * 0.05} rx={size * 0.01} fill={c} />;
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: showName ? 8 : 0 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
|
||||
<path d={`M${size / 2} ${size * 0.07} L${size * 0.86} ${size * 0.24} V${size * 0.52} c0 ${size * 0.22}-${size * 0.16} ${size * 0.32}-${size * 0.36} ${size * 0.4} C${size * 0.3} ${size * 0.84}-${0} 0 ${size * 0.14} ${size * 0.52} V${size * 0.24} Z`}
|
||||
fill={`${c}16`} stroke={c} strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<text x={size / 2} y={size * 0.43} textAnchor="middle" fontFamily="'Pretendard',sans-serif" fontWeight="800" fontSize={size * 0.26} fill={c}>{rank.name}</text>
|
||||
{Array.from({ length: rank.pips }).map((_, i) => pip(i))}
|
||||
</svg>
|
||||
{showName && (
|
||||
<div style={{ lineHeight: 1.1 }}>
|
||||
<div style={{ fontFamily: "'Chakra Petch',sans-serif", fontWeight: 600, fontSize: 13, color: c }}>{rank.name}</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--t3)' }}>{rank.group}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function wrColor(wr) { return wr >= 60 ? '#48515f' : wr >= 50 ? '#48515f' : '#e5484d'; }
|
||||
|
||||
// ── 승률 링 ──
|
||||
function WinRing({ wr, size = 64, sw = 6, label = '승률', color }) {
|
||||
const r = (size - sw) / 2, c = 2 * Math.PI * r;
|
||||
const col = color || wrColor(wr);
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#eceef1" strokeWidth={sw} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={col} strokeWidth={sw} strokeLinecap="round"
|
||||
strokeDasharray={c} strokeDashoffset={c * (1 - wr / 100)} />
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', textAlign: 'center', lineHeight: 1 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: "'Chakra Petch',sans-serif", fontWeight: 700, fontSize: size * 0.28, color: col }}>{wr}<span style={{ fontSize: size * 0.16 }}>%</span></div>
|
||||
<div style={{ fontSize: size * 0.13, color: 'var(--t3)', marginTop: 2 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KDA 칩 ──
|
||||
function KdaChip({ k, d, a, big }) {
|
||||
return (
|
||||
<span style={{ fontFamily: "'Chakra Petch',sans-serif", fontWeight: 600, fontSize: big ? 16 : 13, letterSpacing: '.3px' }}>
|
||||
<span style={{ color: 'var(--ink)' }}>{k}</span>
|
||||
<span style={{ color: 'var(--t3)', margin: '0 3px' }}>/</span>
|
||||
<span style={{ color: '#e5484d' }}>{d}</span>
|
||||
<span style={{ color: 'var(--t3)', margin: '0 3px' }}>/</span>
|
||||
<span style={{ color: '#48515f' }}>{a}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// SearchBar — 흰 필드 + 검정 버튼. 라이브 추천 → onPick(player)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function SearchBar({ onPick, big, placeholder = '닉네임으로 전적을 검색하세요' }) {
|
||||
const SA = window.SA;
|
||||
const [q, setQ] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('pointerdown', h, true);
|
||||
return () => document.removeEventListener('pointerdown', h, true);
|
||||
}, []);
|
||||
const matches = q.trim()
|
||||
? SA.allPlayers.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())).slice(0, 6)
|
||||
: [];
|
||||
const recents = SA.recentSearches.map((n) => SA.allPlayers.find((p) => p.name === n)).filter(Boolean);
|
||||
const submit = () => {
|
||||
const found = matches[0] || SA.allPlayers.find((p) => p.name === q.trim());
|
||||
if (found) { onPick(found); setQ(''); setOpen(false); }
|
||||
else if (q.trim()) { onPick(SA.allPlayers[1]); setQ(''); setOpen(false); }
|
||||
};
|
||||
const h = big ? 58 : 44;
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: h, background: '#fff',
|
||||
border: `1.5px solid ${open ? '#48515f' : 'var(--line2)'}`, borderRadius: 4,
|
||||
boxShadow: open ? '0 6px 22px rgba(0,0,0,.10)' : '0 1px 2px rgba(0,0,0,.04)',
|
||||
transition: 'border-color .14s, box-shadow .14s', overflow: 'hidden' }}>
|
||||
<div style={{ paddingLeft: big ? 18 : 13, color: open ? '#48515f' : 'var(--t3)', display: 'flex' }}>
|
||||
<Icon name="search" size={big ? 22 : 18} sw={2} />
|
||||
</div>
|
||||
<input value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()} placeholder={placeholder}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: '#15171c',
|
||||
fontFamily: "'Pretendard',sans-serif", fontSize: big ? 17 : 14.5, padding: `0 ${big ? 16 : 11}px`, fontWeight: 500 }} />
|
||||
<button onClick={submit} style={{ height: '100%', border: 'none', cursor: 'pointer', color: '#fff',
|
||||
background: '#15171c', fontFamily: "'Chakra Petch',sans-serif", fontWeight: 700, fontSize: big ? 15 : 13,
|
||||
padding: `0 ${big ? 26 : 17}px`, letterSpacing: '.5px' }}>검색</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', top: h + 8, left: 0, right: 0, background: '#fff',
|
||||
border: '1px solid var(--line)', borderRadius: 4, boxShadow: '0 16px 40px rgba(20,22,28,.12)',
|
||||
padding: 7, zIndex: 40, overflow: 'hidden', textAlign: 'left' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', padding: '6px 10px 8px', letterSpacing: '.3px' }}>
|
||||
{q.trim() ? `"${q}" 검색 결과` : '최근 검색한 유저'}
|
||||
</div>
|
||||
{(q.trim() ? matches : recents).map((p) => (
|
||||
<button key={p.name} onClick={() => { onPick(p); setQ(''); setOpen(false); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', border: 'none', background: 'transparent',
|
||||
padding: '8px 10px', borderRadius: 4, cursor: 'pointer', textAlign: 'left', transition: 'background .12s' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f6f8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
||||
<RankBadge rank={p.rank} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{ color: '#15171c', fontWeight: 600, fontSize: 14, flexShrink: 0, maxWidth: '62%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{p.clan && <span style={{ fontSize: 11, color: 'var(--t2)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.clan}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)' }}>{p.rank.name} · Lv.{p.level} · 승률 {p.wr}%</div>
|
||||
</div>
|
||||
<Icon name="chevR" size={15} stroke="var(--t3)" />
|
||||
</button>
|
||||
))}
|
||||
{q.trim() && matches.length === 0 && (
|
||||
<div style={{ padding: '14px 10px', color: 'var(--t3)', fontSize: 13 }}>일치하는 유저가 없어요. 닉네임을 확인해 주세요.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 최근검색 칩 ──
|
||||
function RecentChips({ onPick, onDark = false }) {
|
||||
const SA = window.SA;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: onDark ? 'rgba(255,255,255,.55)' : 'var(--t3)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<Icon name="clock" size={13} /> 최근
|
||||
</span>
|
||||
{SA.recentSearches.slice(0, 6).map((n) => {
|
||||
const p = SA.allPlayers.find((x) => x.name === n);
|
||||
return (
|
||||
<button key={n} onClick={() => p && onPick(p)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px 5px 9px', borderRadius: 4,
|
||||
background: onDark ? 'rgba(255,255,255,.08)' : '#fff', border: `1px solid ${onDark ? 'rgba(255,255,255,.16)' : 'var(--line2)'}`, cursor: 'pointer',
|
||||
color: onDark ? '#e7eaee' : '#3a4049', fontSize: 12.5, fontWeight: 500, transition: 'all .12s', fontFamily: "'Pretendard',sans-serif" }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = onDark ? '#fff' : '#15171c'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = onDark ? 'rgba(255,255,255,.16)' : 'var(--line2)'; }}>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 문의 위젯 — 우하단 플로팅 버튼 + 문의 모달
|
||||
// 푸터 '문의하기' 링크는 window 'sa-open-feedback' 이벤트로 연동
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function FeedbackWidget() {
|
||||
const PT = '#48515f';
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [type, setType] = React.useState('불편 사항');
|
||||
const [text, setText] = React.useState('');
|
||||
const [sent, setSent] = React.useState(false);
|
||||
const [files, setFiles] = React.useState([]);
|
||||
const fileRef = React.useRef(null);
|
||||
const addFiles = (list) => {
|
||||
const arr = Array.from(list || []);
|
||||
if (!arr.length) return;
|
||||
setFiles((f) => [...f, ...arr].slice(0, 3));
|
||||
};
|
||||
const fmtSize = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + 'MB' : Math.max(1, Math.round(n / 1024)) + 'KB';
|
||||
React.useEffect(() => {
|
||||
const fn = () => { setOpen(true); setSent(false); };
|
||||
window.addEventListener('sa-open-feedback', fn);
|
||||
return () => window.removeEventListener('sa-open-feedback', fn);
|
||||
}, []);
|
||||
const submit = () => {
|
||||
if (!text.trim()) return;
|
||||
setSent(true);
|
||||
setTimeout(() => { setOpen(false); setText(''); setType('불편 사항'); setSent(false); setFiles([]); }, 1400);
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* 플로팅 버튼 */}
|
||||
<button onClick={() => { setOpen(true); setSent(false); }} title="문의하기"
|
||||
style={{ position: 'fixed', right: 22, bottom: 22, zIndex: 290, display: 'flex', alignItems: 'center', gap: 7, padding: '12px 18px', borderRadius: 26, border: 'none', cursor: 'pointer',
|
||||
background: '#20242b', color: '#fff', fontWeight: 700, fontSize: 13, fontFamily: "'Pretendard',sans-serif", boxShadow: '0 6px 22px rgba(0,0,0,.25)' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 8.6 8.6 0 0 1-3.8-.9L3 20l1-4.9a8.4 8.4 0 1 1 17-3.6z" />
|
||||
</svg>
|
||||
문의
|
||||
</button>
|
||||
|
||||
{/* 문의 모달 */}
|
||||
{open && (
|
||||
<div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(20,22,26,.55)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'sgFade .18s ease' }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 24px 70px rgba(0,0,0,.3)', fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--line)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--ink)' }}>문의하기</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)', marginTop: 2 }}>불편한 점이나 궁금한 점을 알려주세요.</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} style={{ width: 28, height: 28, borderRadius: 6, border: 'none', background: '#eef0f2', color: '#5b626c', cursor: 'pointer', fontSize: 15 }}>×</button>
|
||||
</div>
|
||||
|
||||
{sent ? (
|
||||
<div style={{ padding: '38px 18px', textAlign: 'center' }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: '50%', background: `${PT}14`, display: 'grid', placeItems: 'center', margin: '0 auto 12px' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={PT} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 800, color: 'var(--ink)' }}>문의가 접수되었어요</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--t2)', marginTop: 4 }}>답변은 마이페이지 > 문의 내역에서 확인할 수 있어요.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 }}>문의 유형</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
{['불편 사항', '버그 제보', '기능 제안', '신고', '기타 문의'].map((v) => {
|
||||
const on = type === v;
|
||||
return (
|
||||
<button key={v} onClick={() => setType(v)} style={{ padding: '6px 12px', borderRadius: 5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12,
|
||||
border: `1px solid ${on ? PT : 'var(--line2)'}`, background: on ? PT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{v}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 }}>내용</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="어떤 점이 불편했는지, 어떤 기능이 궁금한지 자유롭게 적어주세요."
|
||||
style={{ width: '100%', resize: 'vertical', padding: '10px 12px', border: '1px solid var(--line2)', borderRadius: 7, fontSize: 13, lineHeight: 1.6, fontFamily: "'Pretendard',sans-serif", color: '#3a4049', outline: 'none', boxSizing: 'border-box', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 11.5, color: 'var(--t3)', marginBottom: 14 }}>답변은 로그인한 계정의 <b style={{ color: 'var(--t2)' }}>마이페이지 > 문의 내역</b>으로 드려요.</div>
|
||||
|
||||
{/* 파일 첨부 */}
|
||||
<input ref={fileRef} type="file" multiple accept="image/*,video/*,.log,.txt" style={{ display: 'none' }} onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<button onClick={() => fileRef.current && fileRef.current.click()} disabled={files.length >= 3}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 13px', borderRadius: 7, cursor: files.length >= 3 ? 'not-allowed' : 'pointer', background: '#fff', border: '1px dashed var(--line2)', color: files.length >= 3 ? 'var(--t3)' : 'var(--t2)', fontWeight: 700, fontSize: 12, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.4 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
파일 첨부 <span style={{ fontWeight: 600, color: 'var(--t3)' }}>({files.length}/3)</span>
|
||||
</button>
|
||||
{files.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 8 }}>
|
||||
{files.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f7f8f9', border: '1px solid var(--line)', borderRadius: 6 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--t3)', flexShrink: 0 }}>{fmtSize(f.size)}</span>
|
||||
<button onClick={() => setFiles(files.filter((_, j) => j !== i))} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--t3)', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={submit} disabled={!text.trim()} style={{ width: '100%', padding: '12px 0', borderRadius: 7, border: 'none', background: text.trim() ? PT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 14, cursor: text.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif" }}>보내기</button>
|
||||
|
||||
{/* 비회원 문의 채널 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '16px 0 11px' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--t3)', fontWeight: 600 }}>계정 없이 이용 중이신가요?</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, padding: '10px 0', borderRadius: 7, border: 'none', cursor: 'pointer', background: '#5865F2', color: '#fff', fontWeight: 700, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="#fff"><path d="M19.6 5.6A17.3 17.3 0 0 0 15.4 4l-.5 1a16 16 0 0 0-5.8 0l-.5-1a17.3 17.3 0 0 0-4.2 1.6C1.7 9.6 1 13.5 1.3 17.3A17.5 17.5 0 0 0 6.6 20l1.1-1.8a11 11 0 0 1-1.7-.9l.4-.3a12.4 12.4 0 0 0 11.2 0l.4.3c-.5.4-1.1.7-1.7.9L17.4 20a17.4 17.4 0 0 0 5.3-2.7c.4-4.4-.7-8.2-3.1-11.7zM8.7 14.9c-1 0-1.9-1-1.9-2.1s.8-2.1 1.9-2.1 1.9 1 1.9 2.1-.9 2.1-1.9 2.1zm6.6 0c-1 0-1.9-1-1.9-2.1s.9-2.1 1.9-2.1 1.9 1 1.9 2.1-.8 2.1-1.9 2.1z"/></svg>
|
||||
디스코드 문의
|
||||
</button>
|
||||
<button style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, padding: '10px 0', borderRadius: 7, border: 'none', cursor: 'pointer', background: '#FEE500', color: '#191600', fontWeight: 700, fontSize: 12.5, fontFamily: "'Pretendard',sans-serif" }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="#191600"><path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.7 1.8 5.1 4.5 6.5l-1 3.8c-.1.4.3.7.6.5l4.4-2.9c.5.1 1 .1 1.5.1 5.5 0 10-3.5 10-7.9S17.5 3 12 3z"/></svg>
|
||||
카카오톡 문의
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Icon, Logo, RankBadge, WinRing, KdaChip, SearchBar, RecentChips, wrColor, FeedbackWidget });
|
||||
@@ -0,0 +1,311 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>searchgg · 디자인 토큰</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
/* ── 표면 / 배경 ── */
|
||||
--bg: #ffffff;
|
||||
--bg2: #eef0f2;
|
||||
--surf: #ffffff;
|
||||
--surf2: #f1f3f5;
|
||||
--head: #f1f3f5; /* 패널 헤더바 */
|
||||
/* ── 경계선 ── */
|
||||
--line: #e4e6e9;
|
||||
--line2: #d2d6db;
|
||||
/* ── 텍스트 ── */
|
||||
--ink: #20242b; /* 본문 강조 */
|
||||
--t2: #5b626c; /* 보조 */
|
||||
--t3: #949aa3; /* 약한 / 캡션 */
|
||||
--t-table: #33373d; /* 표 셀 */
|
||||
--placeholder: #9aa1ab;
|
||||
/* ── 브랜드 / 시맨틱 ── */
|
||||
--point: #48515f; /* 메인 포인트(올리브/슬레이트) */
|
||||
--point-soft: rgba(72,81,95,.12);
|
||||
--positive: #48515f; /* 승 / 좋은 승률 */
|
||||
--negative: #e5484d; /* 패 / 데스 / 마감임박 */
|
||||
--up: #16a34a; /* 상승 추세 */
|
||||
--accent-blue: #2f6fed;/* 그래프 승률선 / 블루 엠블럼 */
|
||||
--gold: #c0901a; /* 영관 계급 / 챔피언 */
|
||||
/* ── 외부 채널 ── */
|
||||
--discord: #5865F2;
|
||||
--kakao: #FEE500;
|
||||
--kakao-ink: #191600;
|
||||
--chzzk: #00b6a0;
|
||||
--soop: #0064ff;
|
||||
--youtube: #e62117;
|
||||
/* ── 모양 ── */
|
||||
--r-xs: 4px; --r-sm: 5px; --r-md: 7px; --r-lg: 8px; --r-xl: 14px; --r-pill: 20px;
|
||||
/* ── 그림자 ── */
|
||||
--sh-card: 0 1px 2px rgba(0,0,0,.04);
|
||||
--sh-hover: 0 4px 14px rgba(0,0,0,.10);
|
||||
--sh-pop: 0 6px 22px rgba(0,0,0,.10);
|
||||
--sh-modal: 0 24px 70px rgba(0,0,0,.34);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg2); }
|
||||
body { font-family: 'Pretendard', sans-serif; color: var(--ink); -webkit-font-smoothing: antialiased; }
|
||||
.num { font-family: 'Chakra Petch', sans-serif; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 28px 80px; }
|
||||
|
||||
/* 헤더 */
|
||||
header.top { background: linear-gradient(135deg,#23262b,#2e333b); color: #fff; }
|
||||
header.top .inner { max-width: 1080px; margin: 0 auto; padding: 40px 28px 34px; }
|
||||
.kicker { font-family: 'Chakra Petch', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 2.5px; color: rgba(255,255,255,.55); }
|
||||
header.top h1 { margin: 10px 0 0; font-size: 32px; font-weight: 800; letter-spacing: -.6px; }
|
||||
header.top p { margin: 9px 0 0; font-size: 14px; color: rgba(255,255,255,.72); line-height: 1.6; }
|
||||
.brand { font-family: 'Chakra Petch', sans-serif; font-weight: 700; letter-spacing: -.5px; }
|
||||
|
||||
section { margin-top: 40px; }
|
||||
.sec-head { display: flex; align-items: baseline; gap: 10px; padding-bottom: 12px; margin-bottom: 20px; border-bottom: 2px solid var(--ink); }
|
||||
.sec-head h2 { margin: 0; font-size: 19px; font-weight: 800; }
|
||||
.sec-head .en { font-family: 'Chakra Petch', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 2px; color: var(--t3); }
|
||||
.sec-note { font-size: 13px; color: var(--t2); margin: -8px 0 18px; line-height: 1.6; }
|
||||
|
||||
/* 스와치 그리드 */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.g4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.g3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.g2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
.swatch { background: #fff; border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--sh-card); }
|
||||
.swatch .chip { height: 76px; }
|
||||
.swatch .meta { padding: 10px 12px; }
|
||||
.swatch .nm { font-size: 13px; font-weight: 700; }
|
||||
.swatch .var { font-family: 'Chakra Petch', sans-serif; font-size: 11.5px; color: var(--t2); margin-top: 2px; }
|
||||
.swatch .hex { font-family: 'Chakra Petch', sans-serif; font-size: 11.5px; color: var(--t3); text-transform: uppercase; margin-top: 1px; }
|
||||
|
||||
/* 램프 */
|
||||
.ramp { display: flex; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--line); box-shadow: var(--sh-card); }
|
||||
.ramp > div { flex: 1; height: 64px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 6px; font-family: 'Chakra Petch', sans-serif; font-size: 10px; color: rgba(255,255,255,.92); font-weight: 600; }
|
||||
|
||||
/* 타이포 표본 */
|
||||
.type-row { display: flex; align-items: baseline; gap: 18px; padding: 14px 0; border-bottom: 1px solid var(--line); }
|
||||
.type-row:last-child { border-bottom: none; }
|
||||
.type-row .label { width: 150px; flex-shrink: 0; font-size: 12px; color: var(--t3); font-weight: 600; }
|
||||
.type-row .label b { display: block; color: var(--t2); font-size: 12.5px; font-weight: 700; }
|
||||
.type-row .spec { width: 130px; flex-shrink: 0; font-family: 'Chakra Petch', sans-serif; font-size: 12px; color: var(--t3); }
|
||||
|
||||
/* 토큰 표 */
|
||||
table.tok { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--sh-card); }
|
||||
table.tok th, table.tok td { text-align: left; padding: 11px 16px; font-size: 13px; border-bottom: 1px solid var(--line); }
|
||||
table.tok thead th { background: var(--surf2); font-size: 11px; font-weight: 700; color: var(--t3); letter-spacing: .3px; }
|
||||
table.tok tbody tr:last-child td { border-bottom: none; }
|
||||
table.tok td.k { font-family: 'Chakra Petch', sans-serif; font-weight: 700; color: var(--ink); width: 220px; }
|
||||
table.tok td.v { font-family: 'Chakra Petch', sans-serif; color: var(--t2); }
|
||||
.demo-box { display: inline-flex; align-items: center; gap: 10px; }
|
||||
|
||||
/* 컴포넌트 미니 */
|
||||
.comp-card { background: #fff; border: 1px solid var(--line); border-radius: var(--r-lg); padding: 18px; box-shadow: var(--sh-card); }
|
||||
.btn { font-family: 'Pretendard', sans-serif; font-weight: 700; font-size: 13px; border-radius: var(--r-md); padding: 10px 16px; cursor: default; border: 1px solid var(--line2); }
|
||||
.btn.primary { background: var(--point); color: #fff; border: none; }
|
||||
.btn.ghost { background: #fff; color: #3a4049; }
|
||||
.pill { display: inline-block; padding: 6px 12px; border-radius: var(--r-pill); font-size: 12px; font-weight: 700; border: 1px solid var(--line2); color: var(--t2); }
|
||||
.pill.on { background: var(--point); color: #fff; border-color: var(--point); }
|
||||
.tag { display: inline-block; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11.5px; font-weight: 600; color: var(--point); background: var(--point-soft); }
|
||||
.codeblock { background: #1c1f24; color: #e7e9ec; border-radius: var(--r-lg); padding: 18px 20px; font-family: 'Chakra Petch', monospace; font-size: 12.5px; line-height: 1.7; overflow-x: auto; white-space: pre; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="inner">
|
||||
<div class="kicker">SEARCH.GG · DESIGN TOKENS</div>
|
||||
<h1><span class="brand">search<span style="color:#8b94a0">.gg</span></span> 디자인 토큰</h1>
|
||||
<p>서든어택 전적검색 서비스의 색·타이포·간격·컴포넌트 토큰 정의서입니다.<br/>밝은 표면 + 올리브/슬레이트 포인트, 숫자는 Chakra Petch, 본문은 Pretendard를 사용합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- 표면 / 텍스트 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>표면 & 텍스트</h2><span class="en">SURFACE / TEXT</span></div>
|
||||
<div class="grid g4" id="surface"></div>
|
||||
</section>
|
||||
|
||||
<!-- 브랜드 / 시맨틱 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>브랜드 & 시맨틱</h2><span class="en">BRAND / SEMANTIC</span></div>
|
||||
<p class="sec-note">포인트 컬러는 올리브-슬레이트 <b>#48515f</b> 하나로 통일합니다. 승/긍정은 포인트색, 패/데스/위험은 레드, 추세 상승은 그린을 씁니다.</p>
|
||||
<div class="grid g4" id="semantic"></div>
|
||||
</section>
|
||||
|
||||
<!-- 외부 채널 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>외부 채널</h2><span class="en">CHANNELS</span></div>
|
||||
<div class="grid g3" id="channel"></div>
|
||||
</section>
|
||||
|
||||
<!-- 계급 색 램프 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>계급 색 램프</h2><span class="en">MILITARY RANK</span></div>
|
||||
<p class="sec-note">사병(슬레이트) → 부사관(브론즈) → 준·위관(블루그레이) → 영관·장성(골드)으로 이어지는 군 계급 색입니다.</p>
|
||||
<div class="ramp" id="rankramp"></div>
|
||||
</section>
|
||||
|
||||
<!-- 랭크전 티어 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>랭크전 티어 색</h2><span class="en">RANKED TIER</span></div>
|
||||
<div class="ramp" id="tierramp"></div>
|
||||
</section>
|
||||
|
||||
<!-- 엠블럼 팔레트 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>클랜 엠블럼 팔레트</h2><span class="en">EMBLEM</span></div>
|
||||
<p class="sec-note">클랜 엠블럼 배경에 쓰는 8색. 심볼 색은 흰색 + 파스텔 4색을 조합합니다.</p>
|
||||
<div class="ramp" id="emblemramp" style="margin-bottom:12px"></div>
|
||||
<div class="ramp" id="symbolramp"></div>
|
||||
</section>
|
||||
|
||||
<!-- 타이포그래피 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>타이포그래피</h2><span class="en">TYPE</span></div>
|
||||
<p class="sec-note"><b>Pretendard</b> — 본문·UI 전반(400~800). <b>Chakra Petch</b> — 숫자·지표·라벨·로고(500/600/700).</p>
|
||||
<div class="comp-card" id="type"></div>
|
||||
</section>
|
||||
|
||||
<!-- 간격 / 라디우스 / 그림자 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>모양 & 깊이</h2><span class="en">RADIUS / SHADOW</span></div>
|
||||
<div class="grid g2">
|
||||
<table class="tok">
|
||||
<thead><tr><th>라디우스</th><th>값</th><th>용도</th></tr></thead>
|
||||
<tbody id="radius"></tbody>
|
||||
</table>
|
||||
<table class="tok">
|
||||
<thead><tr><th>그림자</th><th>용도</th></tr></thead>
|
||||
<tbody id="shadow"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 컴포넌트 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>핵심 컴포넌트</h2><span class="en">COMPONENTS</span></div>
|
||||
<div class="comp-card">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:18px">
|
||||
<span class="btn primary">기본 버튼</span>
|
||||
<span class="btn ghost">보조 버튼</span>
|
||||
<span class="pill on">선택됨</span>
|
||||
<span class="pill">필터 칩</span>
|
||||
<span class="tag">즐겜/친목팟</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:13px;color:var(--t2)">
|
||||
<span>승 <b class="num" style="color:var(--positive)">128승</b></span>
|
||||
<span>패 <b class="num" style="color:var(--negative)">74패</b></span>
|
||||
<span>K/D/A <b class="num"><span style="color:var(--ink)">24</span> / <span style="color:var(--negative)">11</span> / <span style="color:var(--point)">7</span></b></span>
|
||||
<span>추세 <b class="num" style="color:var(--up)">▲6</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CSS 토큰 코드 -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>CSS 토큰</h2><span class="en">:ROOT</span></div>
|
||||
<p class="sec-note">아래 블록을 그대로 스타일시트 최상단에 붙여 넣어 사용합니다.</p>
|
||||
<div class="codeblock" id="csscode"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const surface = [
|
||||
['배경', '--bg', '#ffffff'], ['앱 배경', '--bg2', '#eef0f2'],
|
||||
['표면2', '--surf2', '#f1f3f5'], ['패널 헤더', '--head', '#f1f3f5'],
|
||||
['경계선', '--line', '#e4e6e9'], ['진한 경계', '--line2', '#d2d6db'],
|
||||
['본문 강조', '--ink', '#20242b'], ['보조 텍스트', '--t2', '#5b626c'],
|
||||
['약한 텍스트', '--t3', '#949aa3'], ['표 셀', '--t-table', '#33373d'],
|
||||
['플레이스홀더', '--placeholder', '#9aa1ab'], ['표면', '--surf', '#ffffff'],
|
||||
];
|
||||
const semantic = [
|
||||
['포인트', '--point', '#48515f'], ['포인트 소프트', '--point-soft', 'rgba(72,81,95,.12)'],
|
||||
['긍정 / 승', '--positive', '#48515f'], ['부정 / 패·데스', '--negative', '#e5484d'],
|
||||
['상승 추세', '--up', '#16a34a'], ['그래프 블루', '--accent-blue', '#2f6fed'],
|
||||
['골드 / 챔피언', '--gold', '#c0901a'], ['위험 / 마감임박', '--negative', '#e5484d'],
|
||||
];
|
||||
const channel = [
|
||||
['디스코드', '--discord', '#5865F2'], ['카카오 오픈챗', '--kakao', '#FEE500'],
|
||||
['치지직', '--chzzk', '#00b6a0'], ['숲 (SOOP)', '--soop', '#0064ff'],
|
||||
['유튜브', '--youtube', '#e62117'], ['카카오 잉크', '--kakao-ink', '#191600'],
|
||||
];
|
||||
const rankramp = ['#737d8a','#525c68','#b3712b','#9e6022','#566f8e','#415d7e','#c0901a','#ad800c','#9c7b1e','#7d6111'];
|
||||
const ranknames = ['이병','병장','하사','원사','준위','대위','소령','대령','준장','대장'];
|
||||
const tiers = [['브론즈','#a9743b'],['실버','#94a0aa'],['골드','#c4960f'],['마스터','#5b8a72'],['그랜드마스터','#4f6b8a'],['챌린저','#7a5bb0'],['랭커','#b25a2b'],['하이랭커','#b23b3b']];
|
||||
const emblem = ['#48515f','#2f6fed','#c0392b','#1f8a5b','#8e44ad','#d98a1f','#16708a','#33373d'];
|
||||
const symbols = [['#ffffff','흰'],['#ffe08a','골드'],['#ffd1d1','로즈'],['#bfe3ff','스카이'],['#c9f0d8','민트']];
|
||||
|
||||
const isLight = (hex) => {
|
||||
const h = hex.replace('#',''); if (h.length < 6) return false;
|
||||
const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
|
||||
return (0.299*r + 0.587*g + 0.114*b) > 150;
|
||||
};
|
||||
const swatch = (nm, v, hex) => {
|
||||
const tc = isLight(hex) ? '#20242b' : '#fff';
|
||||
return `<div class="swatch"><div class="chip" style="background:${hex}"></div><div class="meta"><div class="nm">${nm}</div><div class="var">var(${v})</div><div class="hex">${hex}</div></div></div>`;
|
||||
};
|
||||
document.getElementById('surface').innerHTML = surface.map(s => swatch(...s)).join('');
|
||||
document.getElementById('semantic').innerHTML = semantic.map(s => swatch(...s)).join('');
|
||||
document.getElementById('channel').innerHTML = channel.map(s => swatch(...s)).join('');
|
||||
|
||||
document.getElementById('rankramp').innerHTML = rankramp.map((c,i) => `<div style="background:${c}">${ranknames[i]}</div>`).join('');
|
||||
document.getElementById('tierramp').innerHTML = tiers.map(([n,c]) => `<div style="background:${c};color:${isLight(c)?'#20242b':'#fff'}">${n}</div>`).join('');
|
||||
document.getElementById('emblemramp').innerHTML = emblem.map(c => `<div style="background:${c}">${c.slice(1)}</div>`).join('');
|
||||
document.getElementById('symbolramp').innerHTML = symbols.map(([c,n]) => `<div style="background:${c};color:#20242b;border-right:1px solid #e4e6e9">${n}</div>`).join('');
|
||||
|
||||
const types = [
|
||||
['페이지 타이틀', 'Pretendard 800 · 30px', '<span style="font-size:30px;font-weight:800;letter-spacing:-.6px">전적 검색</span>'],
|
||||
['섹션 헤더', 'Pretendard 800 · 19px', '<span style="font-size:19px;font-weight:800">시즌별 랭크전 전적</span>'],
|
||||
['패널 제목', 'Pretendard 800 · 15px', '<span style="font-size:15px;font-weight:800">클랜원</span>'],
|
||||
['본문', 'Pretendard 500 · 14px', '<span style="font-size:14px;font-weight:500">분위기 좋은 우리 클랜에 놀러오세요.</span>'],
|
||||
['보조 / 캡션', 'Pretendard 600 · 12px', '<span style="font-size:12px;font-weight:600;color:#5b626c">최근 14전 · 9승 5패</span>'],
|
||||
['지표 숫자', 'Chakra Petch 700 · 22px', '<span class="num" style="font-size:22px;font-weight:700">68<span style="font-size:14px">%</span></span>'],
|
||||
['라벨 / EN', 'Chakra Petch 700 · 11px · ls 2px', '<span class="num" style="font-size:11px;font-weight:700;letter-spacing:2px;color:#949aa3">RANKED</span>'],
|
||||
['로고', 'Chakra Petch 700 · ls -.5px', '<span class="brand" style="font-size:20px">search<span style="color:#48515f">.gg</span></span>'],
|
||||
];
|
||||
document.getElementById('type').innerHTML = types.map(([l,s,d]) =>
|
||||
`<div class="type-row"><div class="label"><b>${l}</b></div><div class="spec">${s}</div><div>${d}</div></div>`).join('');
|
||||
|
||||
const radius = [['--r-xs','4px','인풋·작은 배지'],['--r-sm','5px','기본 카드·패널'],['--r-md','7px','버튼·인풋'],['--r-lg','8px','큰 카드'],['--r-xl','14px','모달'],['--r-pill','20px','칩·태그']];
|
||||
document.getElementById('radius').innerHTML = radius.map(([k,v,u]) =>
|
||||
`<tr><td class="k">${k}</td><td class="v"><span class="demo-box"><span style="width:34px;height:24px;background:var(--point-soft);border:1px solid var(--line2);border-radius:${v}"></span>${v}</span></td><td>${u}</td></tr>`).join('');
|
||||
|
||||
const shadow = [['--sh-card','카드 기본'],['--sh-hover','카드 호버'],['--sh-pop','드롭다운·검색'],['--sh-modal','모달']];
|
||||
document.getElementById('shadow').innerHTML = shadow.map(([k,u]) =>
|
||||
`<tr><td class="k">${k}</td><td><span class="demo-box"><span style="width:46px;height:30px;background:#fff;border-radius:7px;box-shadow:var(${k})"></span><span style="color:var(--t2)">${u}</span></span></td></tr>`).join('');
|
||||
|
||||
document.getElementById('csscode').textContent =
|
||||
`:root {
|
||||
/* 표면 */
|
||||
--bg:#ffffff; --bg2:#eef0f2; --surf2:#f1f3f5; --head:#f1f3f5;
|
||||
--line:#e4e6e9; --line2:#d2d6db;
|
||||
/* 텍스트 */
|
||||
--ink:#20242b; --t2:#5b626c; --t3:#949aa3; --placeholder:#9aa1ab;
|
||||
/* 시맨틱 */
|
||||
--point:#48515f; --point-soft:rgba(72,81,95,.12);
|
||||
--positive:#48515f; --negative:#e5484d; --up:#16a34a;
|
||||
--accent-blue:#2f6fed; --gold:#c0901a;
|
||||
/* 채널 */
|
||||
--discord:#5865F2; --kakao:#FEE500; --chzzk:#00b6a0; --soop:#0064ff; --youtube:#e62117;
|
||||
/* 라디우스 */
|
||||
--r-xs:4px; --r-sm:5px; --r-md:7px; --r-lg:8px; --r-xl:14px; --r-pill:20px;
|
||||
/* 그림자 */
|
||||
--sh-card:0 1px 2px rgba(0,0,0,.04);
|
||||
--sh-hover:0 4px 14px rgba(0,0,0,.10);
|
||||
--sh-pop:0 6px 22px rgba(0,0,0,.10);
|
||||
--sh-modal:0 24px 70px rgba(0,0,0,.34);
|
||||
}
|
||||
|
||||
/* 폰트 */
|
||||
body { font-family:'Pretendard',sans-serif; }
|
||||
.num { font-family:'Chakra Petch',sans-serif; } /* 숫자·지표·라벨 */`;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>searchgg · 서든어택 전적검색 — 메인 홈 시안</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --bg2: #eef0f2; --surf: #ffffff; --surf2: #f1f3f5;
|
||||
--line: #e4e6e9; --line2: #d2d6db;
|
||||
--ink: #20242b; --t2: #5b626c; --t3: #949aa3;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: #eef0f2; }
|
||||
body { font-family: 'Pretendard', sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
input::placeholder { color: #9aa1ab; }
|
||||
button { font-family: 'Pretendard', sans-serif; }
|
||||
@keyframes sgFade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
</style>
|
||||
<template id="__bundler_thumbnail">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#ffffff" />
|
||||
<circle cx="50" cy="44" r="18" fill="none" stroke="#48515f" stroke-width="2.6" />
|
||||
<path d="M50 22v7M50 59v7M28 44h7M65 44h7" stroke="#48515f" stroke-width="2.6" stroke-linecap="round" />
|
||||
<circle cx="50" cy="44" r="3.4" fill="#48515f" />
|
||||
<text x="50" y="86" text-anchor="middle" font-family="monospace" font-size="13" font-weight="700" fill="#20242b">search<tspan fill="#48515f">.gg</tspan></text>
|
||||
</svg>
|
||||
</template>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="image-slot.js"></script>
|
||||
<script type="text/babel" src="sa-data.jsx"></script>
|
||||
<script type="text/babel" src="sa-shared.jsx"></script>
|
||||
<script type="text/babel" src="sa-profile-sections.jsx"></script>
|
||||
<script type="text/babel" src="sa-profile.jsx"></script>
|
||||
<script type="text/babel" src="sa-login.jsx"></script>
|
||||
<script type="text/babel" src="sa-clan-data.jsx"></script>
|
||||
<script type="text/babel" src="sa-clan-sections.jsx"></script>
|
||||
<script type="text/babel" src="sa-clan.jsx"></script>
|
||||
<script type="text/babel" src="sa-clan-plaza.jsx"></script>
|
||||
<script type="text/babel" src="sa-community.jsx"></script>
|
||||
<script type="text/babel" src="sa-home.jsx"></script>
|
||||
|
||||
<script type="text/babel" data-presets="react">
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<Home />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user