|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useState, useMemo } from "react"; |
| 4 | +import { CopyButton } from "./CopyButton"; |
| 5 | + |
| 6 | +type SuffixStrategy = "pubkey" | "role" | "number"; |
| 7 | + |
| 8 | +export default function CompanionNamer() { |
| 9 | + const [emoji, setEmoji] = useState(""); |
| 10 | + const [handle, setHandle] = useState(""); |
| 11 | + const [strategy, setStrategy] = useState<SuffixStrategy>("pubkey"); |
| 12 | + const [pubkeyPrefix, setPubkeyPrefix] = useState(""); |
| 13 | + const [role, setRole] = useState(""); |
| 14 | + const [number, setNumber] = useState(""); |
| 15 | + |
| 16 | + const suffix = useMemo(() => { |
| 17 | + switch (strategy) { |
| 18 | + case "pubkey": |
| 19 | + return pubkeyPrefix.toUpperCase(); |
| 20 | + case "role": |
| 21 | + return role.toUpperCase(); |
| 22 | + case "number": |
| 23 | + return number ? `MY${number.padStart(2, "0")}` : ""; |
| 24 | + } |
| 25 | + }, [strategy, pubkeyPrefix, role, number]); |
| 26 | + |
| 27 | + const generatedName = useMemo(() => { |
| 28 | + const parts: string[] = []; |
| 29 | + if (emoji) parts.push(emoji); |
| 30 | + if (handle) parts.push(handle.toUpperCase()); |
| 31 | + if (suffix) parts.push(suffix); |
| 32 | + return parts.join(" "); |
| 33 | + }, [emoji, handle, suffix]); |
| 34 | + |
| 35 | + // Count visible characters (emoji = ~2 display chars, but counts as 1 in MeshCore) |
| 36 | + const charCount = generatedName.length; |
| 37 | + const isOverLimit = charCount > 23; |
| 38 | + |
| 39 | + const strategies: { value: SuffixStrategy; label: string; desc: string }[] = [ |
| 40 | + { value: "pubkey", label: "Public key prefix", desc: "4 hex chars from your public key (default)" }, |
| 41 | + { value: "role", label: "Role", desc: "2-4 chars: PRIM, SCND, HOME, etc." }, |
| 42 | + { value: "number", label: "Number", desc: "01-99 (auto-prefixed with MY)" }, |
| 43 | + ]; |
| 44 | + |
| 45 | + return ( |
| 46 | + <div className="space-y-6"> |
| 47 | + {/* Live Preview */} |
| 48 | + <div className="card-mesh p-5 text-center"> |
| 49 | + <p className="text-xs text-foreground-muted uppercase tracking-wider mb-2"> |
| 50 | + Companion Name Preview |
| 51 | + </p> |
| 52 | + <div className="flex items-center justify-center gap-2"> |
| 53 | + <p |
| 54 | + className={`font-mono text-xl md:text-2xl font-bold ${ |
| 55 | + isOverLimit ? "text-red-500" : generatedName ? "text-mesh" : "text-foreground-muted" |
| 56 | + }`} |
| 57 | + > |
| 58 | + {generatedName || "\uD83D\uDC7B M3SHGH\u00D8ST F4"} |
| 59 | + </p> |
| 60 | + {generatedName && <CopyButton text={generatedName} />} |
| 61 | + </div> |
| 62 | + <span |
| 63 | + className={`text-sm font-mono ${ |
| 64 | + isOverLimit ? "text-red-500 font-bold" : "text-foreground-muted" |
| 65 | + }`} |
| 66 | + > |
| 67 | + {charCount}/23 chars |
| 68 | + </span> |
| 69 | + </div> |
| 70 | + |
| 71 | + {/* Emoji */} |
| 72 | + <div> |
| 73 | + <label className="block text-sm font-semibold text-foreground mb-1"> |
| 74 | + Emoji |
| 75 | + </label> |
| 76 | + <p className="text-xs text-foreground-muted mb-2"> |
| 77 | + One emoji per person. Claim yours in Discord first. |
| 78 | + </p> |
| 79 | + <input |
| 80 | + type="text" |
| 81 | + value={emoji} |
| 82 | + onChange={(e) => { |
| 83 | + // Allow a single emoji (grapheme cluster) |
| 84 | + const val = e.target.value; |
| 85 | + const segments = [...new Intl.Segmenter().segment(val)]; |
| 86 | + setEmoji(segments.length > 0 ? segments[0].segment : ""); |
| 87 | + }} |
| 88 | + placeholder="\uD83D\uDC7B" |
| 89 | + className="w-20 bg-night-800/50 border border-card-border rounded-lg px-4 py-2.5 text-foreground text-2xl text-center focus:ring-2 focus:ring-mesh focus:border-mesh outline-none" |
| 90 | + /> |
| 91 | + </div> |
| 92 | + |
| 93 | + {/* Handle */} |
| 94 | + <div> |
| 95 | + <label className="block text-sm font-semibold text-foreground mb-1"> |
| 96 | + Handle |
| 97 | + </label> |
| 98 | + <p className="text-xs text-foreground-muted mb-2"> |
| 99 | + Your mesh alias (not your real name). Max 10 characters. |
| 100 | + </p> |
| 101 | + <input |
| 102 | + type="text" |
| 103 | + value={handle} |
| 104 | + onChange={(e) => |
| 105 | + setHandle( |
| 106 | + e.target.value.replace(/[^a-zA-Z0-9\u00D8]/g, "").slice(0, 10) |
| 107 | + ) |
| 108 | + } |
| 109 | + placeholder="e.g. M3SHGH\u00D8ST" |
| 110 | + maxLength={10} |
| 111 | + className="w-full bg-night-800/50 border border-card-border rounded-lg px-4 py-2.5 text-foreground font-mono uppercase focus:ring-2 focus:ring-mesh focus:border-mesh outline-none placeholder:text-foreground-muted/50" |
| 112 | + /> |
| 113 | + </div> |
| 114 | + |
| 115 | + {/* Suffix Strategy */} |
| 116 | + <div> |
| 117 | + <label className="block text-sm font-semibold text-foreground mb-1"> |
| 118 | + Identification Suffix |
| 119 | + </label> |
| 120 | + <p className="text-xs text-foreground-muted mb-3"> |
| 121 | + How do you want to identify this device? |
| 122 | + </p> |
| 123 | + <div className="space-y-2"> |
| 124 | + {strategies.map((s) => ( |
| 125 | + <label |
| 126 | + key={s.value} |
| 127 | + className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${ |
| 128 | + strategy === s.value |
| 129 | + ? "border-mesh bg-mesh/10" |
| 130 | + : "border-card-border bg-night-800/20 hover:border-mesh/50" |
| 131 | + }`} |
| 132 | + > |
| 133 | + <input |
| 134 | + type="radio" |
| 135 | + name="suffix-strategy" |
| 136 | + value={s.value} |
| 137 | + checked={strategy === s.value} |
| 138 | + onChange={() => setStrategy(s.value)} |
| 139 | + className="mt-0.5 text-mesh focus:ring-mesh" |
| 140 | + /> |
| 141 | + <div> |
| 142 | + <span className="text-sm font-semibold text-foreground"> |
| 143 | + {s.label} |
| 144 | + </span> |
| 145 | + <p className="text-xs text-foreground-muted">{s.desc}</p> |
| 146 | + </div> |
| 147 | + </label> |
| 148 | + ))} |
| 149 | + </div> |
| 150 | + |
| 151 | + {/* Strategy-specific input */} |
| 152 | + <div className="mt-3"> |
| 153 | + {strategy === "pubkey" && ( |
| 154 | + <input |
| 155 | + type="text" |
| 156 | + value={pubkeyPrefix} |
| 157 | + onChange={(e) => |
| 158 | + setPubkeyPrefix( |
| 159 | + e.target.value.replace(/[^a-fA-F0-9]/g, "").slice(0, 4) |
| 160 | + ) |
| 161 | + } |
| 162 | + placeholder="e.g. F4A2" |
| 163 | + maxLength={4} |
| 164 | + className="w-full bg-night-800/50 border border-card-border rounded-lg px-4 py-2.5 text-foreground font-mono uppercase focus:ring-2 focus:ring-mesh focus:border-mesh outline-none placeholder:text-foreground-muted/50" |
| 165 | + /> |
| 166 | + )} |
| 167 | + {strategy === "role" && ( |
| 168 | + <input |
| 169 | + type="text" |
| 170 | + value={role} |
| 171 | + onChange={(e) => |
| 172 | + setRole( |
| 173 | + e.target.value.replace(/[^a-zA-Z]/g, "").slice(0, 4) |
| 174 | + ) |
| 175 | + } |
| 176 | + placeholder="e.g. PRIM, SCND, HOME" |
| 177 | + maxLength={4} |
| 178 | + className="w-full bg-night-800/50 border border-card-border rounded-lg px-4 py-2.5 text-foreground font-mono uppercase focus:ring-2 focus:ring-mesh focus:border-mesh outline-none placeholder:text-foreground-muted/50" |
| 179 | + /> |
| 180 | + )} |
| 181 | + {strategy === "number" && ( |
| 182 | + <input |
| 183 | + type="text" |
| 184 | + value={number} |
| 185 | + onChange={(e) => |
| 186 | + setNumber( |
| 187 | + e.target.value.replace(/[^0-9]/g, "").slice(0, 2) |
| 188 | + ) |
| 189 | + } |
| 190 | + placeholder="e.g. 01" |
| 191 | + maxLength={2} |
| 192 | + className="w-full bg-night-800/50 border border-card-border rounded-lg px-4 py-2.5 text-foreground font-mono uppercase focus:ring-2 focus:ring-mesh focus:border-mesh outline-none placeholder:text-foreground-muted/50" |
| 193 | + /> |
| 194 | + )} |
| 195 | + </div> |
| 196 | + </div> |
| 197 | + |
| 198 | + {/* Do Not rules */} |
| 199 | + <div className="card-mesh p-4 bg-sunset-500/5 border-sunset-500/20"> |
| 200 | + <p className="text-sm font-semibold text-foreground mb-2">Do Not:</p> |
| 201 | + <div className="grid grid-cols-1 sm:grid-cols-2 gap-1 text-sm text-foreground-muted"> |
| 202 | + {[ |
| 203 | + "Use your real name", |
| 204 | + "Put hardware in the name", |
| 205 | + "Use different emojis per device", |
| 206 | + "Take someone else\u2019s emoji", |
| 207 | + "Go over 23 characters", |
| 208 | + ].map((rule, i) => ( |
| 209 | + <div key={i} className="flex items-center gap-2"> |
| 210 | + <span className="text-sunset-500">✕</span> |
| 211 | + <span>{rule}</span> |
| 212 | + </div> |
| 213 | + ))} |
| 214 | + </div> |
| 215 | + </div> |
| 216 | + </div> |
| 217 | + ); |
| 218 | +} |
0 commit comments