SHA256
Compare commits
5
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f8900e531a | ||
|
|
6e5b57fd7c | ||
|
|
408e474130 | ||
|
|
ef707ec217 | ||
|
|
8b12760dde |
+2
-2
@@ -20,7 +20,7 @@ public final class ArweaveBlockPublisherScheduler {
|
||||
ArweaveBlocksConfig cfg;
|
||||
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
||||
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
||||
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; }
|
||||
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; }
|
||||
if (!STARTED.compareAndSet(false,true)) return;
|
||||
try {
|
||||
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
||||
@@ -29,7 +29,7 @@ public final class ArweaveBlockPublisherScheduler {
|
||||
});
|
||||
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
||||
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
||||
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
|
||||
log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes());
|
||||
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
|
||||
}
|
||||
|
||||
|
||||
+44
-10
@@ -8,23 +8,56 @@ import shine.db.entities.BlockEntry;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
|
||||
/** Publishes locally-created signed user DataItems through the configured transport. */
|
||||
public final class ArweaveBlockPublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final ArweaveL1Uploader uploader;
|
||||
private final ArweaveL1Uploader arweaveUploader;
|
||||
private final TurboDataItemUploader turboUploader;
|
||||
|
||||
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||
this.cfg = cfg;
|
||||
this.uploader = new ArweaveL1Uploader(cfg);
|
||||
this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null;
|
||||
this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null;
|
||||
}
|
||||
|
||||
public int runCycle() throws Exception {
|
||||
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||
if (candidates.isEmpty()) return 0;
|
||||
return switch (cfg.publishMode()) {
|
||||
case TURBO -> publishTurbo(candidates);
|
||||
case ARWEAVE -> publishDirectArweave(candidates);
|
||||
case NONE -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
||||
int published = 0;
|
||||
Exception firstFailure = null;
|
||||
for (BlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
try {
|
||||
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||
blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||
published++;
|
||||
log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber());
|
||||
} catch (Exception ex) {
|
||||
if (firstFailure == null) firstFailure = ex;
|
||||
log.warn("Turbo publish failed: chain={} block={} bytes={} error={}",
|
||||
e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||
}
|
||||
}
|
||||
if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published);
|
||||
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
long estimated = 32;
|
||||
@@ -39,7 +72,9 @@ public final class ArweaveBlockPublisherService {
|
||||
e.getBchName(), e.getBlockNumber(), raw.length);
|
||||
continue;
|
||||
}
|
||||
items.add(raw); ids.add(id); estimated = next;
|
||||
items.add(raw);
|
||||
ids.add(id);
|
||||
estimated = next;
|
||||
}
|
||||
if (items.isEmpty()) return 0;
|
||||
|
||||
@@ -47,13 +82,12 @@ public final class ArweaveBlockPublisherService {
|
||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"),
|
||||
// Deliberately different from App=test5590 so discovery returns child DataItems only.
|
||||
new ArweaveL1Uploader.Tag("App", "test5590-batch")
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||
);
|
||||
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
|
||||
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length);
|
||||
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||
blocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||
items.size(), result.txId(), bundle.length);
|
||||
return items.size();
|
||||
}
|
||||
}
|
||||
|
||||
+105
-38
@@ -14,7 +14,9 @@ import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -24,8 +26,9 @@ import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
|
||||
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
|
||||
* Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes.
|
||||
* The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo
|
||||
* or inside a direct server-created ANS-104 bundle.
|
||||
*/
|
||||
public final class ArweaveBlockSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
||||
@@ -33,7 +36,10 @@ public final class ArweaveBlockSyncService {
|
||||
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||
@@ -52,7 +58,7 @@ public final class ArweaveBlockSyncService {
|
||||
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||
String cursor = null;
|
||||
long maxHeight = minHeight;
|
||||
Map<String, byte[]> rootCache = new HashMap<>();
|
||||
long lowestRetryHeight = Long.MAX_VALUE;
|
||||
int discovered = 0;
|
||||
|
||||
do {
|
||||
@@ -66,36 +72,40 @@ public final class ArweaveBlockSyncService {
|
||||
for (JsonNode edge : edges) {
|
||||
nextCursor = edge.path("cursor").asText(null);
|
||||
JsonNode node = edge.path("node");
|
||||
String itemIdText = node.path("id").asText("").trim();
|
||||
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
||||
String dataItemId = node.path("id").asText("").trim();
|
||||
long height = node.path("block").path("height").asLong(-1L);
|
||||
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
||||
if (dataItemId.isBlank() || height < 0) continue;
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
byte[] id32;
|
||||
try { id32 = B64URL.decode(itemIdText); }
|
||||
catch (IllegalArgumentException bad) { continue; }
|
||||
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||
|
||||
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
||||
try { return downloadRootBundle(key); }
|
||||
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
||||
});
|
||||
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
||||
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
||||
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
||||
discovered++;
|
||||
byte[] id32;
|
||||
try {
|
||||
id32 = B64URL.decode(dataItemId);
|
||||
if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length);
|
||||
} catch (Exception e) {
|
||||
log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||
|
||||
try {
|
||||
byte[] rawDataItem = downloadSignedDataItem(dataItemId);
|
||||
if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++;
|
||||
} catch (Exception e) {
|
||||
lowestRetryHeight = Math.min(lowestRetryHeight, height);
|
||||
log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage());
|
||||
}
|
||||
}
|
||||
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||
cursor = hasNext ? nextCursor : null;
|
||||
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
||||
} while (cursor != null);
|
||||
|
||||
// Keep one-height overlap: the next query includes this height and deduplicates IDs.
|
||||
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
|
||||
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
|
||||
// Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item.
|
||||
long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight);
|
||||
importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis());
|
||||
if (discovered > 0) {
|
||||
log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private void drainQueue() throws Exception {
|
||||
@@ -108,13 +118,12 @@ public final class ArweaveBlockSyncService {
|
||||
if (pending.isEmpty()) break;
|
||||
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
||||
Ans104DataItem item;
|
||||
BchBlockEntry block;
|
||||
try {
|
||||
item = new Ans104DataItem(q.rawDataItem());
|
||||
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
|
||||
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
|
||||
block = new BchBlockEntry(q.rawDataItem());
|
||||
new BchBlockEntry(q.rawDataItem());
|
||||
} catch (Exception e) {
|
||||
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
||||
continue;
|
||||
@@ -158,7 +167,10 @@ public final class ArweaveBlockSyncService {
|
||||
|
||||
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
|
||||
String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\""
|
||||
+ ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:"
|
||||
+ cfg.syncPageSize() + ", after:" + after
|
||||
+ ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }";
|
||||
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||
@@ -168,18 +180,73 @@ public final class ArweaveBlockSyncService {
|
||||
return MAPPER.readTree(resp.body());
|
||||
}
|
||||
|
||||
private byte[] downloadRootBundle(String txId) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(5)).GET().build();
|
||||
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
byte[] body = resp.body();
|
||||
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
|
||||
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
|
||||
return body;
|
||||
/**
|
||||
* Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed
|
||||
* DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it.
|
||||
*/
|
||||
private byte[] downloadSignedDataItem(String dataItemId) throws Exception {
|
||||
JsonNode offsets = getOffsets(dataItemId);
|
||||
String rootTxId = offsets.path("rootTxId").asText("").trim();
|
||||
long rootOffset = offsets.path("rootOffset").asLong(-1L);
|
||||
long size = offsets.path("size").asLong(-1L);
|
||||
if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) {
|
||||
throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets);
|
||||
}
|
||||
if (size > cfg.syncMaxDataItemBytes()) {
|
||||
throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size);
|
||||
}
|
||||
if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow");
|
||||
long endInclusive = rootOffset + size - 1;
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId))
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.header("Range", "bytes=" + rootOffset + "-" + endInclusive)
|
||||
.header("Accept", "application/octet-stream")
|
||||
.GET().build();
|
||||
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||
try (InputStream in = resp.body()) {
|
||||
if (resp.statusCode() != 206) {
|
||||
throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode());
|
||||
}
|
||||
byte[] bytes = readExactlyBounded(in, (int) size);
|
||||
if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length);
|
||||
Ans104DataItem parsed = new Ans104DataItem(bytes);
|
||||
byte[] expectedId = B64URL.decode(dataItemId);
|
||||
if (!Arrays.equals(parsed.id32(), expectedId)) {
|
||||
throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode getOffsets(String dataItemId) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId))
|
||||
.timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet");
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body()));
|
||||
}
|
||||
return MAPPER.readTree(resp.body());
|
||||
}
|
||||
|
||||
private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(expected);
|
||||
byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))];
|
||||
int remaining = expected;
|
||||
while (remaining > 0) {
|
||||
int n = in.read(buf, 0, Math.min(buf.length, remaining));
|
||||
if (n < 0) break;
|
||||
out.write(buf, 0, n);
|
||||
remaining -= n;
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0, 500);
|
||||
}
|
||||
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
||||
}
|
||||
|
||||
+41
-8
@@ -1,12 +1,14 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
|
||||
/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */
|
||||
public record ArweaveBlocksConfig(
|
||||
boolean publishEnabled,
|
||||
PublishMode publishMode,
|
||||
int publishIntervalMinutes,
|
||||
int publishMaxItems,
|
||||
long publishMaxBundleBytes,
|
||||
@@ -15,22 +17,44 @@ public record ArweaveBlocksConfig(
|
||||
int minConfirmations,
|
||||
int confirmPollSeconds,
|
||||
int confirmTimeoutMinutes,
|
||||
String turboUploadUrl,
|
||||
String turboPaidByAddress,
|
||||
Path turboWalletJwkPath,
|
||||
boolean syncEnabled,
|
||||
int syncIntervalMinutes,
|
||||
int syncPageSize,
|
||||
int syncQueueBatchSize,
|
||||
long syncStartBlockHeight,
|
||||
long syncMaxRootBundleBytes,
|
||||
long syncMaxDataItemBytes,
|
||||
String syncGateway
|
||||
) {
|
||||
public enum PublishMode {
|
||||
TURBO,
|
||||
ARWEAVE,
|
||||
NONE;
|
||||
|
||||
static PublishMode parse(String value) {
|
||||
String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "turbo" -> TURBO;
|
||||
case "arweave" -> ARWEAVE;
|
||||
case "none", "" -> NONE;
|
||||
default -> throw new IllegalArgumentException(
|
||||
"arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static final String TEST_TAG_NAME = "App";
|
||||
public static final String TEST_TAG_VALUE = "test5590";
|
||||
public static final String CHANNEL_TAG_NAME = "c";
|
||||
public static final String CHANNEL_TAG_NAME = "c_test5590";
|
||||
|
||||
public static ArweaveBlocksConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES);
|
||||
if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||
return new ArweaveBlocksConfig(
|
||||
c.getBoolean("arweave.blocks.publish.enabled", false),
|
||||
PublishMode.parse(c.getParam("arweave.blocks.publish.mode")),
|
||||
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
||||
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
||||
@@ -39,23 +63,32 @@ public record ArweaveBlocksConfig(
|
||||
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
|
||||
orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"),
|
||||
blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")),
|
||||
optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")),
|
||||
c.getBoolean("arweave.blocks.sync.enabled", false),
|
||||
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
||||
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
||||
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
||||
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
|
||||
positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"),
|
||||
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||
);
|
||||
}
|
||||
|
||||
public boolean publishEnabled() { return publishMode != PublishMode.NONE; }
|
||||
|
||||
public void validatePublisher() {
|
||||
if (publishEnabled && walletJwkPath == null) {
|
||||
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
|
||||
if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) {
|
||||
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave");
|
||||
}
|
||||
if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) {
|
||||
throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo");
|
||||
}
|
||||
}
|
||||
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); }
|
||||
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
|
||||
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
|
||||
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */
|
||||
public final class TurboDataItemUploader {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder();
|
||||
|
||||
public record UploadResult(String dataItemId, String owner) {}
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
private volatile String resolvedPaidByAddress;
|
||||
|
||||
public TurboDataItemUploader(ArweaveBlocksConfig cfg) {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
}
|
||||
|
||||
public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception {
|
||||
if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty");
|
||||
Ans104DataItem item = new Ans104DataItem(rawDataItem);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature");
|
||||
if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) {
|
||||
throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id");
|
||||
}
|
||||
|
||||
String expectedId = B64URL.encodeToString(item.id32());
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl()))
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem));
|
||||
String paidBy = paidByAddress();
|
||||
if (paidBy != null) request.header("x-paid-by", paidBy);
|
||||
|
||||
HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
String body = response.body() == null ? "" : response.body().trim();
|
||||
if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) {
|
||||
return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
String hint = response.statusCode() == 402
|
||||
? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)"
|
||||
: "";
|
||||
throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body));
|
||||
}
|
||||
|
||||
if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||
JsonNode json;
|
||||
try { json = MAPPER.readTree(body); }
|
||||
catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); }
|
||||
String returnedId = json.path("id").asText("").trim();
|
||||
if (!returnedId.isBlank() && !returnedId.equals(expectedId)) {
|
||||
throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId);
|
||||
}
|
||||
return new UploadResult(expectedId, json.path("owner").asText(""));
|
||||
}
|
||||
|
||||
/**
|
||||
* x-paid-by contains the payer's public native address, never the private key.
|
||||
* A configured Arweave JWK is used only to derive that public address.
|
||||
*/
|
||||
private String paidByAddress() throws Exception {
|
||||
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||
synchronized (this) {
|
||||
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||
String explicit = cfg.turboPaidByAddress();
|
||||
if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim();
|
||||
if (cfg.turboWalletJwkPath() == null) {
|
||||
resolvedPaidByAddress = "";
|
||||
return null;
|
||||
}
|
||||
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8));
|
||||
String modulus = jwk.path("n").asText("").trim();
|
||||
if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n");
|
||||
byte[] owner = B64URL_DECODER.decode(modulus);
|
||||
resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner));
|
||||
return resolvedPaidByAddress;
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0, 500);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -16,7 +16,7 @@ final class Ans104DataItemTest {
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
new Ans104DataItem.Tag("c_test5590", "books")
|
||||
);
|
||||
|
||||
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
@@ -28,7 +28,7 @@ final class Ans104DataItemTest {
|
||||
assertArrayEquals(owner, parsed.owner32());
|
||||
assertArrayEquals(data, parsed.data());
|
||||
assertTrue(parsed.hasTag("App", "test5590"));
|
||||
assertEquals("books", parsed.tagValue("c"));
|
||||
assertEquals("books", parsed.tagValue("c_test5590"));
|
||||
assertTrue(parsed.verifySignature());
|
||||
assertEquals(32, parsed.id32().length);
|
||||
}
|
||||
@@ -51,11 +51,11 @@ final class Ans104DataItemTest {
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
new Ans104DataItem.Tag("c_test5590", "books")
|
||||
);
|
||||
|
||||
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
|
||||
byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87");
|
||||
assertArrayEquals(expected, actual);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_22 = 22;
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final int SCHEMA_VERSION_24 = 24;
|
||||
public static final int SCHEMA_VERSION_25 = 25;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -65,6 +66,7 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -230,6 +232,10 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_24;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_25) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -6,12 +6,12 @@ import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */
|
||||
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
|
||||
public final class ArweaveBlockImportDAO {
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight,
|
||||
public record QueueItem(byte[] dataItemId, long blockHeight,
|
||||
byte[] rawDataItem, String status, String lastError,
|
||||
long firstSeenAtMs, long updatedAtMs) {}
|
||||
|
||||
@@ -51,22 +51,21 @@ public final class ArweaveBlockImportDAO {
|
||||
}
|
||||
|
||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_import_queue(
|
||||
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?,?,'',?,?)
|
||||
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?, '',?,?)
|
||||
ON CONFLICT(data_item_id) DO NOTHING
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
ps.setString(2, rootTxId);
|
||||
ps.setLong(3, blockHeight);
|
||||
ps.setBytes(4, rawDataItem);
|
||||
ps.setString(5, STATUS_PENDING);
|
||||
ps.setLong(2, blockHeight);
|
||||
ps.setBytes(3, rawDataItem);
|
||||
ps.setString(4, STATUS_PENDING);
|
||||
ps.setLong(5, nowMs);
|
||||
ps.setLong(6, nowMs);
|
||||
ps.setLong(7, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
@@ -82,7 +81,7 @@ public final class ArweaveBlockImportDAO {
|
||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
FROM arweave_block_import_queue
|
||||
WHERE status=?
|
||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||
@@ -95,7 +94,7 @@ public final class ArweaveBlockImportDAO {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new QueueItem(
|
||||
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("data_item_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ public final class BlocksDAO {
|
||||
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,
|
||||
block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
@@ -48,7 +48,6 @@ public final class BlocksDAO {
|
||||
ps.setBytes(i++, e.getDataItemId());
|
||||
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||
setNullableString(ps, i++, e.getArweaveRootTxId());
|
||||
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||
setNullableInt(ps, i++, e.getLineCode());
|
||||
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||
@@ -74,7 +73,7 @@ public final class BlocksDAO {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE arweave_publish_pending = TRUE
|
||||
@@ -89,18 +88,18 @@ public final class BlocksDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||
String sql = """
|
||||
UPDATE blocks
|
||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
|
||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
|
||||
WHERE data_item_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch();
|
||||
ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
|
||||
}
|
||||
ps.executeBatch(); c.commit();
|
||||
} catch (Exception e) { c.rollback(); throw e; }
|
||||
@@ -133,7 +132,7 @@ public final class BlocksDAO {
|
||||
private static String baseSelect(){return """
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
""";}
|
||||
@@ -144,7 +143,7 @@ public final class BlocksDAO {
|
||||
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
|
||||
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
|
||||
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ public class BlockEntry {
|
||||
private byte[] dataItemId;
|
||||
private boolean arweavePublishPending;
|
||||
private Long arweavePublishedAtMs;
|
||||
private String arweaveRootTxId;
|
||||
|
||||
private Integer editedByBlockNumber;
|
||||
|
||||
@@ -95,8 +94,6 @@ public class BlockEntry {
|
||||
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||
public String getArweaveRootTxId() { return arweaveRootTxId; }
|
||||
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
|
||||
|
||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems.
|
||||
ALTER TABLE blocks
|
||||
DROP COLUMN IF EXISTS arweave_root_tx_id;
|
||||
|
||||
ALTER TABLE arweave_block_import_queue
|
||||
DROP COLUMN IF EXISTS root_tx_id;
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version=25,
|
||||
updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
|
||||
WHERE id=1;
|
||||
@@ -500,7 +500,6 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
data_item_id BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
arweave_root_tx_id TEXT,
|
||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
@@ -535,7 +534,6 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
@@ -2081,7 +2079,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
+2
-2
@@ -409,10 +409,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>.
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||
try {
|
||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c");
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
|
||||
if (expectedChannelSlug != null) {
|
||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
|
||||
+10
@@ -10,6 +10,7 @@ import shine.db.dao.SyncServersDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -46,6 +47,11 @@ public final class AddBlockSyncService {
|
||||
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
||||
|
||||
public void replicateAsync(String blockchainName, int blockNumber) {
|
||||
if (!isEnabled()) {
|
||||
log.debug("AddBlock sync skipped: blockchain.sync.enabled=false blockchainName={} blockNumber={}",
|
||||
blockchainName, blockNumber);
|
||||
return;
|
||||
}
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
replicate(blockchainName, blockNumber);
|
||||
@@ -56,6 +62,10 @@ public final class AddBlockSyncService {
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
|
||||
}
|
||||
|
||||
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
||||
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
||||
if (ownerLogin == null) {
|
||||
|
||||
+19
@@ -327,6 +327,25 @@ public final class SolanaUsersSyncService
|
||||
state.lastSeenSignature()
|
||||
);
|
||||
|
||||
if (state.lastSeenSignature() == null) {
|
||||
log.warn(
|
||||
"History checkpoint is empty. Running current-state full snapshot bootstrap instead of replaying all historical transactions."
|
||||
);
|
||||
|
||||
runFullSnapshotFallback(
|
||||
state,
|
||||
fetchResult,
|
||||
nowMs
|
||||
);
|
||||
|
||||
markReadyAfterSync(
|
||||
state,
|
||||
nowMs
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.lastSeenSignature() != null
|
||||
&& !fetchResult.anchorFound()) {
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.List;
|
||||
@@ -56,6 +57,10 @@ public final class PeriodicBlockchainSyncService {
|
||||
private PeriodicBlockchainSyncService() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Periodic blockchain sync disabled by blockchain.sync.enabled=false");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +73,10 @@ public final class PeriodicBlockchainSyncService {
|
||||
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
|
||||
}
|
||||
|
||||
private static void runCycleSafe() {
|
||||
try {
|
||||
runCycle();
|
||||
|
||||
@@ -27,6 +27,13 @@ solana.users.sync.pollIntervalSeconds=300
|
||||
# ------------------------------------------------------------
|
||||
sync.importUserProfileFromPartner.enabled=false
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Прямая межсерверная синхронизация пользовательских блокчейнов
|
||||
# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock.
|
||||
# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить.
|
||||
# ------------------------------------------------------------
|
||||
blockchain.sync.enabled=true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Server public info
|
||||
# Эти поля используются JSON-операцией GetServerInfo.
|
||||
@@ -131,11 +138,14 @@ test.freeAvatar.walletJwkPath=
|
||||
# ============================================================
|
||||
# Arweave per-user-block transport (ANS-104)
|
||||
# Test namespace: each user DataItem is signed with App=test5590.
|
||||
# Channel DataItems additionally contain c=<canonical-channel-slug>.
|
||||
# Channel DataItems additionally contain c_test5590=<canonical-channel-slug>.
|
||||
# publish.mode: turbo | arweave | none
|
||||
# ============================================================
|
||||
arweave.blocks.publish.enabled=false
|
||||
arweave.blocks.publish.mode=none
|
||||
arweave.blocks.publish.intervalMinutes=15
|
||||
arweave.blocks.publish.maxItems=10000
|
||||
|
||||
# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle.
|
||||
arweave.blocks.publish.maxBundleBytes=134217728
|
||||
arweave.blocks.publish.gateway=https://arweave.net
|
||||
arweave.blocks.publish.walletJwkPath=
|
||||
@@ -143,10 +153,18 @@ arweave.blocks.publish.minConfirmations=0
|
||||
arweave.blocks.publish.confirmPollSeconds=30
|
||||
arweave.blocks.publish.confirmTimeoutMinutes=180
|
||||
|
||||
# Turbo: uploads each already user-signed DataItem separately, without re-signing it.
|
||||
# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set,
|
||||
# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo.
|
||||
# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer.
|
||||
arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
|
||||
arweave.blocks.publish.turbo.paidByAddress=
|
||||
arweave.blocks.publish.turbo.walletJwkPath=
|
||||
|
||||
arweave.blocks.sync.enabled=false
|
||||
arweave.blocks.sync.intervalMinutes=15
|
||||
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
||||
arweave.blocks.sync.pageSize=100
|
||||
arweave.blocks.sync.queueBatchSize=10000
|
||||
arweave.blocks.sync.startBlockHeight=0
|
||||
arweave.blocks.sync.maxRootBundleBytes=268435456
|
||||
arweave.blocks.sync.maxDataItemBytes=8388608
|
||||
|
||||
@@ -82,7 +82,7 @@ public final class AddBlockSender {
|
||||
List<Ans104DataItem.Tag> tags = new ArrayList<>();
|
||||
tags.add(new Ans104DataItem.Tag("App", "test5590"));
|
||||
String channelSlug = channelSlugFor(body);
|
||||
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug));
|
||||
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug));
|
||||
|
||||
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
|
||||
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.21
|
||||
server.version=1.10.8
|
||||
client.version=1.13.1
|
||||
server.version=1.11.0
|
||||
|
||||
@@ -43,6 +43,24 @@
|
||||
- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service.
|
||||
- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`.
|
||||
|
||||
## Временные статические сайты
|
||||
|
||||
Для дизайн-примеров, standalone-viewer'ов и временных тестовых страниц используется папка:
|
||||
|
||||
```text
|
||||
shine-UI/static-sites/
|
||||
```
|
||||
|
||||
Она деплоится обычным UI deploy вместе со всем `shine-UI`. Если Caddy root указывает на UI-каталог и используется стандартный fallback `try_files {path} /index.html`, отдельный Caddy-route для каждой новой подпапки не нужен.
|
||||
|
||||
Примеры URL после deploy:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/
|
||||
https://<host>/static-sites/design-examples/channels-v1/
|
||||
https://<host>/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Production wrappers:
|
||||
|
||||
- `scripts/production_shineupme_server.sh`
|
||||
|
||||
@@ -82,7 +82,7 @@ App = test5590
|
||||
Если блок относится к конкретному каналу, он дополнительно содержит:
|
||||
|
||||
```text
|
||||
c = <canonical_channel_slug>
|
||||
c_test5590 = <canonical_channel_slug>
|
||||
```
|
||||
|
||||
Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
|
||||
@@ -107,7 +107,7 @@ Slug входит в подпись DataItem и не может быть изм
|
||||
|
||||
1. распарсить полный ANS-104 DataItem;
|
||||
2. проверить `App=test5590`;
|
||||
3. проверить `c`, если тип блока требует канал;
|
||||
3. проверить `c_test5590`, если тип блока требует канал;
|
||||
4. проверить ANS-104 Ed25519 подпись;
|
||||
5. проверить, что `owner` равен текущему blockchain public key пользователя;
|
||||
6. распарсить Frame v1 и body;
|
||||
|
||||
@@ -2,96 +2,155 @@
|
||||
|
||||
## Цель
|
||||
|
||||
Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle.
|
||||
Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle.
|
||||
|
||||
## Child DataItem tags
|
||||
Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят.
|
||||
|
||||
Обязательно для тестового контура:
|
||||
## User DataItem tags
|
||||
|
||||
Для тестового контура обязательно:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
|
||||
Дополнительно для блоков конкретного канала:
|
||||
Для блоков конкретного канала дополнительно:
|
||||
|
||||
```text
|
||||
c=<canonical_channel_slug>
|
||||
c_test5590=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
Теги входят в ANS-104 подпись пользователя.
|
||||
Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag.
|
||||
|
||||
## Publisher
|
||||
## Publisher modes
|
||||
|
||||
По умолчанию цикл — раз в 15 минут.
|
||||
Настройка:
|
||||
|
||||
```text
|
||||
arweave.blocks.publish.mode=turbo | arweave | none
|
||||
```
|
||||
|
||||
### `turbo`
|
||||
|
||||
```text
|
||||
blocks.arweave_publish_pending=true
|
||||
↓
|
||||
готовые serialized DataItems
|
||||
готовый signed user DataItem из blocks.block_bytes
|
||||
↓
|
||||
ANS-104 binary bundle
|
||||
POST в Turbo как application/octet-stream
|
||||
↓
|
||||
обычная Arweave L1 transaction
|
||||
Turbo bundling / Arweave
|
||||
```
|
||||
|
||||
Если pending-блоков нет, транзакция не создаётся.
|
||||
DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же.
|
||||
|
||||
Root transaction содержит стандартные bundle tags:
|
||||
Для Turbo можно задать публичный payer address напрямую:
|
||||
|
||||
```text
|
||||
arweave.blocks.publish.turbo.paidByAddress=...
|
||||
```
|
||||
|
||||
либо путь к серверному Arweave JWK:
|
||||
|
||||
```text
|
||||
arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json
|
||||
```
|
||||
|
||||
Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает.
|
||||
|
||||
Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending.
|
||||
|
||||
### `arweave`
|
||||
|
||||
Сохраняется прежний fallback:
|
||||
|
||||
```text
|
||||
pending user DataItems
|
||||
↓
|
||||
standard ANS-104 binary bundle
|
||||
↓
|
||||
server Arweave RSA/JWK signature
|
||||
↓
|
||||
Arweave L1
|
||||
```
|
||||
|
||||
Root transaction содержит только стандартные bundle tags:
|
||||
|
||||
```text
|
||||
Bundle-Format=binary
|
||||
Bundle-Version=2.0.0
|
||||
Content-Type=application/octet-stream
|
||||
App=test5590-batch
|
||||
```
|
||||
|
||||
`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles.
|
||||
Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`.
|
||||
|
||||
После успешной L1-загрузки сервер ставит child-блокам:
|
||||
### `none`
|
||||
|
||||
Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо.
|
||||
|
||||
## Состояние публикации в БД
|
||||
|
||||
После успешной публикации:
|
||||
|
||||
- `arweave_publish_pending=false`;
|
||||
- `arweave_published_at_ms`;
|
||||
- `arweave_root_tx_id`.
|
||||
- заполняется `arweave_published_at_ms`.
|
||||
|
||||
## Importer
|
||||
`arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`.
|
||||
|
||||
Каждый сервер может независимо искать:
|
||||
## Importer: только individual DataItems
|
||||
|
||||
Importer всегда выполняет один discovery-запрос:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
|
||||
через GraphQL gateway с cursor pagination.
|
||||
Он **не ищет root bundles** и не зависит от `publish.mode`.
|
||||
|
||||
Для каждого нового DataItem:
|
||||
Это одинаково работает для:
|
||||
|
||||
1. взять `id` и `bundledIn.id`;
|
||||
2. получить root bundle;
|
||||
3. извлечь точные serialized bytes child DataItem по bundle index;
|
||||
4. проверить `dataItemId == SHA256(signature)`;
|
||||
5. проверить ANS-104 Ed25519 подпись;
|
||||
6. определить пользователя по `owner`;
|
||||
7. применить обычные проверки `AddBlock`;
|
||||
8. записать в PostgreSQL с `arweave_publish_pending=false`.
|
||||
- DataItem, отправленного через Turbo;
|
||||
- DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера.
|
||||
|
||||
### Блоки могут прийти не по порядку
|
||||
После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags.
|
||||
|
||||
Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается.
|
||||
### Получение полного signed DataItem
|
||||
|
||||
## Дедупликация и несколько серверов
|
||||
Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes.
|
||||
|
||||
Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален.
|
||||
Поэтому importer:
|
||||
|
||||
Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами.
|
||||
1. получает `data_item_id` через GraphQL `App=test5590`;
|
||||
2. запрашивает `GET /ar-io/offsets/{data_item_id}`;
|
||||
3. получает `rootTxId`, `rootOffset`, `size`;
|
||||
4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону;
|
||||
5. разбирает полученные bytes как `Ans104DataItem`;
|
||||
6. проверяет, что `SHA-256(signature) == data_item_id`;
|
||||
7. проверяет Ed25519 ANS-104 signature;
|
||||
8. определяет пользователя по `owner`;
|
||||
9. импортирует через обычную логику `AddBlock` без повторной публикации.
|
||||
|
||||
Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле.
|
||||
|
||||
## Очередь и порядок блоков
|
||||
|
||||
`arweave_block_import_queue` хранит:
|
||||
|
||||
- `data_item_id`;
|
||||
- `block_height`;
|
||||
- полный `raw_data_item`;
|
||||
- status/error/timestamps.
|
||||
|
||||
`root_tx_id` очереди больше не нужен.
|
||||
|
||||
Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается.
|
||||
|
||||
## Дедупликация
|
||||
|
||||
`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave.
|
||||
|
||||
Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования.
|
||||
|
||||
## Локальное хранение
|
||||
|
||||
Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL.
|
||||
|
||||
## Настройки
|
||||
|
||||
См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
||||
|
||||
## Что намеренно не входит в этот патч
|
||||
|
||||
Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key.
|
||||
Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
|
||||
- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита.
|
||||
- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию.
|
||||
- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки.
|
||||
- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи.
|
||||
- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`.
|
||||
- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25.
|
||||
|
||||
## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация
|
||||
- Базовый коммит-ориентир: `3483a0a`.
|
||||
- Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`.
|
||||
- Добавлена возможность отключать прямую межсерверную синхронизацию блоков настройкой `blockchain.sync.enabled=false`.
|
||||
- Arweave-импорт переведён на root ANS-104 bundle discovery: сервер находит batch-транзакции `App=test5590-batch`, разбирает вложенные signed DataItem и импортирует SHiNE-блоки с дедупликацией по `data_item_id`.
|
||||
- Проверен тестовый сценарий Arweave-only: t3 поднял блоки из Arweave без прямой межсерверной синхронизации с t2.
|
||||
|
||||
## 2026-08-25 19:45:18 +0400
|
||||
- Базовый коммит-ориентир: `3a58519`.
|
||||
- Добавлены runtime-агрегаты статистики:
|
||||
|
||||
@@ -1,108 +1,79 @@
|
||||
# Инструкция Codex: применить ANS-104 test5590 patch
|
||||
# Применение patch: Turbo + direct Arweave для `App=test5590`
|
||||
|
||||
## Цель
|
||||
## Что меняется
|
||||
|
||||
Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек.
|
||||
- `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch.
|
||||
- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно.
|
||||
- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle.
|
||||
- `none`: наружу ничего не публикуется.
|
||||
- Importer всегда ищет только individual `App=test5590` DataItems.
|
||||
- `App=test5590-batch` больше не используется.
|
||||
- Channel tag: `c_test5590=<canonical_channel_slug>` вместо `c=...`.
|
||||
- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`.
|
||||
- Схема PostgreSQL: v25.
|
||||
|
||||
## Жёсткое ограничение
|
||||
|
||||
**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`.
|
||||
|
||||
Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch.
|
||||
|
||||
## Применение
|
||||
|
||||
1. Распаковать patch поверх корня репозитория, сохраняя относительные пути.
|
||||
2. Удалить все пути из корневого `DELETE_FILES.txt`.
|
||||
3. Проверить, что `git diff -- ESP32` пуст.
|
||||
4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется.
|
||||
|
||||
## Arweave config
|
||||
|
||||
Минимально для публикации:
|
||||
## Минимальная настройка Turbo
|
||||
|
||||
```properties
|
||||
arweave.blocks.publish.enabled=true
|
||||
arweave.blocks.publish.intervalMinutes=15
|
||||
arweave.blocks.publish.gateway=https://arweave.net
|
||||
arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json
|
||||
arweave.blocks.publish.mode=turbo
|
||||
arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
|
||||
```
|
||||
|
||||
JWK не коммитить.
|
||||
Для действующего free tier маленьких DataItem этого может быть достаточно.
|
||||
|
||||
Для discovery/import:
|
||||
Если upload платный и расходы должны идти с server Turbo Credits:
|
||||
|
||||
```properties
|
||||
arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json
|
||||
# либо вместо JWK сразу публичный адрес:
|
||||
# arweave.blocks.publish.turbo.paidByAddress=<server payer address>
|
||||
```
|
||||
|
||||
JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`.
|
||||
Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки.
|
||||
|
||||
## Direct Arweave fallback
|
||||
|
||||
```properties
|
||||
arweave.blocks.publish.mode=arweave
|
||||
arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json
|
||||
arweave.blocks.publish.gateway=https://arweave.net
|
||||
```
|
||||
|
||||
Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer.
|
||||
|
||||
## Отключение публикации
|
||||
|
||||
```properties
|
||||
arweave.blocks.publish.mode=none
|
||||
```
|
||||
|
||||
Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы.
|
||||
|
||||
## Importer
|
||||
|
||||
```properties
|
||||
arweave.blocks.sync.enabled=true
|
||||
arweave.blocks.sync.intervalMinutes=15
|
||||
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
||||
arweave.blocks.sync.startBlockHeight=0
|
||||
arweave.blocks.sync.maxDataItemBytes=8388608
|
||||
```
|
||||
|
||||
На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю.
|
||||
Importer:
|
||||
|
||||
## Test namespace
|
||||
1. GraphQL `App=test5590`;
|
||||
2. `/ar-io/offsets/<dataItemId>`;
|
||||
3. range `GET /raw/<rootTxId>`;
|
||||
4. проверка exact signed DataItem ID + signature;
|
||||
5. обычный `AddBlock` import.
|
||||
|
||||
Child DataItem:
|
||||
## Миграция БД
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25.
|
||||
|
||||
Channel child:
|
||||
## Проверка после применения
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
c=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
Root bundle:
|
||||
|
||||
```text
|
||||
Bundle-Format=binary
|
||||
Bundle-Version=2.0.0
|
||||
App=test5590-batch
|
||||
```
|
||||
|
||||
Перед production-start test namespace должен быть заменён отдельным осознанным изменением.
|
||||
|
||||
## Проверки после применения
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
node --check shine-UI/js/services/ans104-data-item.js
|
||||
node --check shine-UI/js/services/auth-service.js
|
||||
node --check shine-UI/js/app.js
|
||||
node --check shine-UI/js/pages/settings-view.js
|
||||
```
|
||||
|
||||
Java/Gradle:
|
||||
|
||||
```bash
|
||||
./gradlew testClasses
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`.
|
||||
|
||||
В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде.
|
||||
|
||||
## Smoke scenario
|
||||
|
||||
1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key.
|
||||
2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен.
|
||||
3. Создать channel и post; проверить `c=<canonical slug>`.
|
||||
4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx.
|
||||
5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке.
|
||||
6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`.
|
||||
7. Проверить, что повторный discovery не создаёт дублей.
|
||||
|
||||
## Не делать в этом patch
|
||||
|
||||
- не добавлять backward compatibility Frame v0;
|
||||
- не возвращать `.bch` storage;
|
||||
- не возвращать SHINE-ARCHIVE;
|
||||
- не менять ESP32;
|
||||
- не мигрировать remote/homeserver signing без отдельного решения пользователя;
|
||||
- не заменять `prevHash` на Arweave DataItem ID.
|
||||
1. Создать новый channel/post и проверить signed tag `c_test5590=<canonical slug>`.
|
||||
2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false.
|
||||
3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи.
|
||||
4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID.
|
||||
5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
```text
|
||||
User
|
||||
-> создаёт Frame v1
|
||||
-> tags: App=test5590, при канале c=<slug>
|
||||
-> tags: App=test5590, при канале c_test5590=<slug>
|
||||
-> Ed25519 подписывает ANS-104 deep-hash
|
||||
-> готовый DataItem
|
||||
-> AddBlock
|
||||
@@ -22,8 +22,9 @@ User
|
||||
Server
|
||||
-> verify DataItem + SHiNE chain
|
||||
-> PostgreSQL
|
||||
-> каждые ~15 минут ANS-104 bundle
|
||||
-> Arweave L1
|
||||
-> publish.mode=turbo: каждый signed DataItem через Turbo
|
||||
ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1
|
||||
ИЛИ publish.mode=none: наружу не публиковать
|
||||
|
||||
Other servers
|
||||
-> GraphQL App=test5590
|
||||
|
||||
@@ -22,6 +22,20 @@ PostgreSQL является единственным локальным хран
|
||||
4. последовательно проигрывает удалённую цепочку с блока 0;
|
||||
5. никаких файловых swap/recovery операций нет.
|
||||
|
||||
Этот прямой sync включается параметром:
|
||||
|
||||
```properties
|
||||
blockchain.sync.enabled=true
|
||||
```
|
||||
|
||||
Для проверки режима без прямых server-to-server связей его можно выключить в override-конфиге сервера:
|
||||
|
||||
```properties
|
||||
blockchain.sync.enabled=false
|
||||
```
|
||||
|
||||
При выключенном прямом sync сервер по-прежнему может синхронизировать пользователей из Solana и импортировать пользовательские блоки через Arweave, если включён `arweave.blocks.sync.enabled`.
|
||||
|
||||
## Arweave sync
|
||||
|
||||
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# SHiNE — дизайн приложения
|
||||
|
||||
Версия 0.2 · 22 сентября 2026.
|
||||
|
||||
Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах.
|
||||
|
||||
Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом.
|
||||
|
||||
## 1. Задача и границы
|
||||
|
||||
Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки.
|
||||
|
||||
Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна.
|
||||
|
||||
Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома.
|
||||
|
||||
Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист.
|
||||
|
||||
## 2. Принципы для всего приложения
|
||||
|
||||
1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности.
|
||||
2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние.
|
||||
3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана.
|
||||
4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты.
|
||||
5. Редкие и технические действия помещаются в меню, но остаются доступными.
|
||||
6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст.
|
||||
7. Метки, иконки и расположение действий повторяются во всех разделах.
|
||||
8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе.
|
||||
|
||||
## 3. Принятые решения
|
||||
|
||||
| Решение пользователя | Правило для реализации |
|
||||
| --- | --- |
|
||||
| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки |
|
||||
| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния |
|
||||
| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент |
|
||||
| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой |
|
||||
| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу |
|
||||
| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос |
|
||||
| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас |
|
||||
| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием |
|
||||
| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба |
|
||||
|
||||
Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется.
|
||||
|
||||
## 4. Базовые параметры
|
||||
|
||||
Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места.
|
||||
|
||||
| Параметр | Целевое значение |
|
||||
| --- | --- |
|
||||
| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px |
|
||||
| Имя автора | 14 px / 20 px, вес 600 |
|
||||
| Вторичная информация | 12 px / 18 px |
|
||||
| Заголовок корневого экрана | 24 px / 30 px, вес 600 |
|
||||
| Название канала в компактной панели | 17 px / 22 px, вес 600 |
|
||||
| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 |
|
||||
| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px |
|
||||
| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px |
|
||||
| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу |
|
||||
| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе |
|
||||
| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки |
|
||||
| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха |
|
||||
| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края |
|
||||
| Контраст текста | Цель WCAG AA; проверить утверждённую палитру |
|
||||
| Анимация | 150–200 мс, отключение при reduced motion |
|
||||
|
||||
### 4.1. Семантические цвета двух тем
|
||||
|
||||
Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации.
|
||||
|
||||
| Роль | Ночь | День | Где используется |
|
||||
| --- | --- | --- | --- |
|
||||
| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация |
|
||||
| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла |
|
||||
| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение |
|
||||
| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст |
|
||||
| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки |
|
||||
| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк |
|
||||
| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля |
|
||||
| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация |
|
||||
| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке |
|
||||
| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса |
|
||||
| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка |
|
||||
| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления |
|
||||
| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением |
|
||||
| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением |
|
||||
| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом |
|
||||
|
||||
Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации.
|
||||
|
||||
Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры.
|
||||
|
||||
`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости.
|
||||
|
||||
### 4.2. Выбор темы
|
||||
|
||||
Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения.
|
||||
|
||||
Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала.
|
||||
|
||||
## 5. Список каналов
|
||||
|
||||
Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность.
|
||||
|
||||
Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью.
|
||||
|
||||
Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору».
|
||||
|
||||
Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты.
|
||||
|
||||
Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически.
|
||||
|
||||
## 6. Канал и сообщения
|
||||
|
||||
### 6.1. Компактная шапка
|
||||
|
||||
Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале.
|
||||
|
||||
Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного.
|
||||
|
||||
Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий.
|
||||
|
||||
### 6.2. Публикация
|
||||
|
||||
Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки.
|
||||
|
||||
У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`.
|
||||
|
||||
Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста.
|
||||
|
||||
«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты.
|
||||
|
||||
Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием.
|
||||
|
||||
Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов.
|
||||
|
||||
## 7. Ветка обсуждения
|
||||
|
||||
Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных.
|
||||
|
||||
Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности.
|
||||
|
||||
Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу.
|
||||
|
||||
## 8. Мобильный редактор ответа
|
||||
|
||||
Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа.
|
||||
|
||||
Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты.
|
||||
|
||||
При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала.
|
||||
|
||||
Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее.
|
||||
|
||||
Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран.
|
||||
|
||||
Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе.
|
||||
|
||||
Состояния отправки:
|
||||
|
||||
- Пустой текст без вложений: кнопка неактивна.
|
||||
- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа.
|
||||
- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение.
|
||||
- Отправка: индикатор, блокировка повторного нажатия.
|
||||
- Ошибка: понятная причина, повтор, сохранённый текст.
|
||||
- Успех: новый ответ и восстановленный контекст обсуждения.
|
||||
- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape.
|
||||
|
||||
В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу.
|
||||
|
||||
Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю.
|
||||
|
||||
## 9. Что подтверждено кодом
|
||||
|
||||
| Возможность | Где прочитано | Решение макета |
|
||||
| --- | --- | --- |
|
||||
| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно |
|
||||
| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки |
|
||||
| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца |
|
||||
| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале |
|
||||
| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа |
|
||||
| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство |
|
||||
| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта |
|
||||
| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом |
|
||||
|
||||
Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию.
|
||||
|
||||
## 10. Состояния для следующей итерации
|
||||
|
||||
| Область | Нужные дополнительные макеты |
|
||||
| --- | --- |
|
||||
| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов |
|
||||
| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название |
|
||||
| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип |
|
||||
| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке |
|
||||
| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса |
|
||||
| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion |
|
||||
|
||||
## 11. Общие компоненты для единообразия
|
||||
|
||||
| Компонент | Единое правило |
|
||||
| --- | --- |
|
||||
| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного |
|
||||
| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области |
|
||||
| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия |
|
||||
| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled |
|
||||
| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку |
|
||||
| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка |
|
||||
| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок |
|
||||
| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию |
|
||||
| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект |
|
||||
| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке |
|
||||
| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине |
|
||||
| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера |
|
||||
| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно |
|
||||
| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке |
|
||||
| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления |
|
||||
| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста |
|
||||
|
||||
Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом.
|
||||
|
||||
Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран.
|
||||
|
||||
### 11.1. Общая адаптация
|
||||
|
||||
Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту.
|
||||
|
||||
При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует.
|
||||
|
||||
Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения.
|
||||
|
||||
### 11.2. Применение к остальным разделам
|
||||
|
||||
| Раздел | Что наследует | Что требует отдельного проектирования |
|
||||
| --- | --- | --- |
|
||||
| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки |
|
||||
| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности |
|
||||
| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта |
|
||||
| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления |
|
||||
| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления |
|
||||
| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки |
|
||||
|
||||
Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок.
|
||||
|
||||
## 12. Допущения и отложенные решения
|
||||
|
||||
Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop.
|
||||
|
||||
До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически.
|
||||
|
||||
Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса.
|
||||
|
||||
## 13. Перенос в приложение и критерии готовности
|
||||
|
||||
На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения.
|
||||
|
||||
Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора.
|
||||
|
||||
Матрица приёмки для первого переноса:
|
||||
|
||||
| Проверка | Ожидаемый результат |
|
||||
| --- | --- |
|
||||
| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте |
|
||||
| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются |
|
||||
| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню |
|
||||
| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал |
|
||||
| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки |
|
||||
| Оглавление | Доступно через меню, включая пустое состояние |
|
||||
| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой |
|
||||
| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке |
|
||||
| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст |
|
||||
| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна |
|
||||
| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком |
|
||||
| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются |
|
||||
| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы |
|
||||
| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется |
|
||||
| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается |
|
||||
|
||||
Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне.
|
||||
|
||||
Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы.
|
||||
|
||||
## 14. Журнал решений
|
||||
|
||||
| Дата | Версия | Решение |
|
||||
| --- | --- | --- |
|
||||
| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения |
|
||||
| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята |
|
||||
| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения |
|
||||
@@ -0,0 +1,46 @@
|
||||
# Первый перенос дизайна каналов
|
||||
|
||||
22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2.
|
||||
|
||||
## Реализовано
|
||||
|
||||
- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу.
|
||||
- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск.
|
||||
- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне.
|
||||
- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись.
|
||||
- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён.
|
||||
- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются.
|
||||
- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану.
|
||||
- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись.
|
||||
|
||||
## Проверено автоматически
|
||||
|
||||
Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует.
|
||||
|
||||
- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход.
|
||||
- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup.
|
||||
- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose.
|
||||
- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса.
|
||||
- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`.
|
||||
- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade.
|
||||
|
||||
Воспроизводимый запуск из корня продукта (Node 24):
|
||||
|
||||
```bash
|
||||
check_dir=$(mktemp -d)
|
||||
npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss
|
||||
SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются.
|
||||
|
||||
## Границы и оставшаяся приёмка
|
||||
|
||||
API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён.
|
||||
|
||||
Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются.
|
||||
|
||||
Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты.
|
||||
|
||||
Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Замечания к серверу и UI (найдено при редизайне)
|
||||
|
||||
Код по этим пунктам не менялся.
|
||||
|
||||
## Ошибки
|
||||
|
||||
1. **Лента «Подписки» пуста у логинов с заглавными буквами** (например `User_t2_03`).
|
||||
`Net_ListSubscriptionsFeed_Handler.loadFollowedChannels` ищет `connections_state.login = ?` в исходном регистре,
|
||||
а в `connections_state` логин хранится в нижнем регистре (FK на `solana_user_pda_current.normalized_login`).
|
||||
Подписки в базе есть, но `followedChannels` пуст. Отсюда же «подписка не срабатывает» в UI:
|
||||
кнопка не меняется на «Отписаться», канал не появляется в «Подписках».
|
||||
|
||||
2. **Превью в списке чатов — «Сообщение недоступно»**, хотя в самом чате сообщения расшифровываются.
|
||||
`messages-list.js` строит превью по `dialog.lastMessageBlobB64`, `catch` глушит настоящую ошибку.
|
||||
Проверить, не бывает ли так у реальных пользователей (например, если последнее сообщение отправлено с другого устройства).
|
||||
|
||||
3. **Прямой переход по адресу** `/settings`, `/profile`, `/channels/new` при загрузке страницы уводит на «Личные».
|
||||
|
||||
4. **`?localWsPort=` теряется при навигации**: после обновления страницы UI молча подключается к `shineup.me`.
|
||||
|
||||
## Безопасность / конфигурация
|
||||
|
||||
5. **Приватный ключ web-push в репозитории**: `SHiNE-server/src/main/resources/application.properties` → `webpush.vapid.private`.
|
||||
Если он совпадает с продовым — перевыпустить и убрать из git.
|
||||
|
||||
6. По умолчанию сервер представляется логином прода `server.SHiNE.login=shineupme` — при запуске вне прода легко перепутать.
|
||||
|
||||
## На будущее
|
||||
|
||||
7. **Каждое действие перерисовывает страницу целиком** (лайк → шапка на мгновение «Канал / Загрузка…», лента перестраивается),
|
||||
отсюда «дёрганье» при переходах назад. Нужны оптимистичные обновления (лайк/ответ меняют только свой элемент),
|
||||
кэш загруженных лент между переходами, обновление без полной перерисовки.
|
||||
|
||||
8. **Кэш модулей после деплоя.** Страницы в `js/app.js` подключаются с метками `?v=…`, а `js/components/*` и `js/services/*` — без меток,
|
||||
браузер может держать старую версию. Для изменённых страниц метки подняты до `?v=202609260900`;
|
||||
для остальных модулей стоит проверить заголовки кэширования или ввести общую метку версии, как у CSS и `app.js`.
|
||||
|
||||
## Не сделано в редизайне
|
||||
|
||||
- Автопроверка серверов в настройках.
|
||||
- Число подписчиков в шапке канала (нет в API).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Дизайн SHiNE
|
||||
|
||||
Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню.
|
||||
|
||||
- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки.
|
||||
- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора.
|
||||
|
||||
Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся.
|
||||
|
||||
## Как посмотреть
|
||||
|
||||
Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`.
|
||||
|
||||
Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально.
|
||||
|
||||
Отдельные варианты:
|
||||
|
||||
1. [Чистый монохром](channels-v1/index.html?variant=mono).
|
||||
2. [Тёплый SHiNE](channels-v1/index.html?variant=warm).
|
||||
3. [Ночной SHiNE](channels-v1/index.html?variant=night).
|
||||
4. [Компактный клуб](channels-v1/index.html?variant=club).
|
||||
|
||||
## Что попробовать
|
||||
|
||||
1. Нажать на канал и открыть обсуждение через значок комментариев.
|
||||
2. Ответить на исходное сообщение или конкретному собеседнику.
|
||||
3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы.
|
||||
4. Прикрепить файл, удалить вложение, отправить текст или только вложение.
|
||||
5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно.
|
||||
6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации.
|
||||
7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения.
|
||||
|
||||
Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета.
|
||||
|
||||
## Границы
|
||||
|
||||
Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer.
|
||||
|
||||
## Проверка
|
||||
|
||||
Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Проверка HTML-мокапов
|
||||
|
||||
Дата: 22.09.2026.
|
||||
|
||||
## Выполнено
|
||||
|
||||
- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен.
|
||||
- CSS разобран PostCSS без ошибок синтаксиса.
|
||||
- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации.
|
||||
- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов.
|
||||
- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого.
|
||||
- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`.
|
||||
- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно.
|
||||
- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись.
|
||||
|
||||
Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись.
|
||||
|
||||
## Ограничения проверки
|
||||
|
||||
Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером.
|
||||
|
||||
Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место.
|
||||
|
||||
Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись.
|
||||
|
||||
## Уточнение документа 0.2
|
||||
|
||||
22.09.2026, после выбора пользователем направления:
|
||||
|
||||
- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах.
|
||||
- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню.
|
||||
- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса.
|
||||
- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1.
|
||||
- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет.
|
||||
- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания.
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SHiNE — четыре направления UI</title>
|
||||
<link rel="stylesheet" href="mockups.css">
|
||||
<script src="mockups.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="studio-header">
|
||||
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark">✳</span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
|
||||
<span class="draft-label">Концепции · сентябрь 2026</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="intro">
|
||||
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
|
||||
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
|
||||
</section>
|
||||
<section class="review-bar" aria-label="Управление просмотром">
|
||||
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
|
||||
<button class="review-button selected" data-screen="list" aria-pressed="true">01 Каналы</button>
|
||||
<button class="review-button" data-screen="feed" aria-pressed="false">02 Сообщения</button>
|
||||
<button class="review-button" data-screen="thread" aria-pressed="false">03 Ветка</button>
|
||||
<button class="review-button" data-screen="reply" aria-pressed="false">04 Ответ</button>
|
||||
</div>
|
||||
<div class="review-options"><label>Роль <select id="role"><option value="reader">Читатель</option><option value="owner">Владелец</option></select></label><label class="keyboard-option"><input type="checkbox" id="keyboard"> Макет клавиатуры</label><a class="compare-link" href="index.html">Все варианты ↗</a></div>
|
||||
</section>
|
||||
<div id="gallery" class="gallery"></div>
|
||||
<section class="decision-note"><span class="eyebrow">С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ</span><h2>Сначала удобство. Затем характер.</h2><div class="decision-grid"><p><b>01 / Читаемость</b>Где комфортнее читать длинный пост и замечать новый ответ?</p><p><b>02 / Плотность</b>Нужны просторные сообщения или больше содержимого на одном экране?</p><p><b>03 / Окно ответа</b>Полный экран или лист поверх ветки? Проверьте с макетом клавиатуры.</p></div><p class="disclaimer">Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. <a href="../DESIGN.md">Дизайн-документ ↗</a></p></section>
|
||||
</main>
|
||||
<footer class="studio-footer"><span>SHiNE / пространство для общения</span><span>Рабочий материал · не финальный дизайн</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,212 @@
|
||||
* { box-sizing: border-box; }
|
||||
:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; }
|
||||
body { margin: 0; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, a, select { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: default; opacity: .4; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; }
|
||||
a { color: inherit; }
|
||||
.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
|
||||
.brand-spark { font-size: 36px; font-weight: 400; }
|
||||
.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; }
|
||||
.draft-label { font-size: 12px; color: #63675f; }
|
||||
main { padding: 0 42px; max-width: 1900px; margin: auto; }
|
||||
.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; }
|
||||
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; }
|
||||
h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; }
|
||||
.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; }
|
||||
.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; }
|
||||
.intro-note > span { font-size: 38px; letter-spacing: -2px; }
|
||||
.intro-note p { font-size: 12px; margin-top: 10px; }
|
||||
.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; }
|
||||
.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; }
|
||||
.review-button.selected { background: #292d27; color: white; }
|
||||
.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; }
|
||||
.review-options label { display: flex; align-items: center; gap: 6px; }
|
||||
.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; }
|
||||
.compare-link { display: none; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; }
|
||||
.concept { min-width: 0; }
|
||||
.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; }
|
||||
.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; }
|
||||
.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; }
|
||||
.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; }
|
||||
.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; }
|
||||
.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; }
|
||||
.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; }
|
||||
.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; }
|
||||
.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; }
|
||||
.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; }
|
||||
.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; }
|
||||
.status-icons .icon { height: 16px; width: 16px; }
|
||||
.phone button { color: inherit; }
|
||||
.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; }
|
||||
.icon-btn:hover { background: var(--soft); }
|
||||
.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); }
|
||||
.header-text { flex: 1; min-width: 0; }
|
||||
.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-text small { color: var(--muted); font-size: 10px; }
|
||||
.list-header { padding-left: 22px; }
|
||||
.list-header h3 { font-size: 25px; letter-spacing: -.9px; }
|
||||
.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; }
|
||||
.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; }
|
||||
.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; }
|
||||
.search-field input::placeholder { color: var(--muted); }
|
||||
.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; }
|
||||
.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); }
|
||||
.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); }
|
||||
.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); }
|
||||
.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
|
||||
.channel-row:hover { background: var(--soft); }
|
||||
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; }
|
||||
.avatar.city { background: #ded3bb; color: #635039; }
|
||||
.avatar.blue { background: #bac5d9; color: #354663; }
|
||||
.avatar.rose { background: #d9bcbb; color: #704444; }
|
||||
.avatar.green { background: #b9cdc0; color: #345747; }
|
||||
.avatar.violet { background: #cbc2d9; color: #56476c; }
|
||||
.avatar.self { background: var(--soft); color: var(--accent); }
|
||||
.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; }
|
||||
.channel-copy { min-width: 0; flex: 1; }
|
||||
.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; }
|
||||
.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; }
|
||||
.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); }
|
||||
.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; }
|
||||
.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); }
|
||||
.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); }
|
||||
.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
|
||||
.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; }
|
||||
.text-button .icon { width: 16px; height: 16px; }
|
||||
.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; }
|
||||
.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; }
|
||||
.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); }
|
||||
.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); }
|
||||
.post-header { display: flex; align-items: center; gap: 9px; }
|
||||
.post-header .avatar { width: 34px; height: 34px; font-size: 11px; }
|
||||
.author { min-width: 0; flex: 1; font-size: 12px; }
|
||||
.author strong { font-weight: 650; }
|
||||
.author small { color: var(--muted); font-size: 10px; display: block; }
|
||||
.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); }
|
||||
.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; }
|
||||
.post-title { font-weight: 600; }
|
||||
.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; }
|
||||
.landscape svg { width: 100%; height: 100%; display: block; }
|
||||
.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; }
|
||||
.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); }
|
||||
.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); }
|
||||
.action[aria-pressed="true"] { color: #dc7477; }
|
||||
.action[aria-pressed="true"] .icon { fill: currentColor; }
|
||||
.reply-link { margin-left: auto; font-size: 10px; }
|
||||
.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; }
|
||||
.attachment > .icon { color: var(--accent); }
|
||||
.attachment strong { font-size: 11px; display: block; }
|
||||
.attachment small { color: var(--muted); display: block; font-size: 10px; }
|
||||
.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; }
|
||||
.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; }
|
||||
.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; }
|
||||
.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
|
||||
.thread-heading strong { color: var(--text); }
|
||||
.reply { display: flex; padding: 16px 18px 0; gap: 10px; }
|
||||
.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
|
||||
.reply .avatar { width: 32px; height: 32px; font-size: 10px; }
|
||||
.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.reply small { color: var(--muted); font-size: 10px; }
|
||||
.reply strong { font-size: 12px; }
|
||||
.reply .post-actions { gap: 16px; }
|
||||
.reply-context { color: var(--accent); font-size: 10px; }
|
||||
.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||
.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; }
|
||||
.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; }
|
||||
.sheet.full { height: 100%; border-radius: 0; }
|
||||
.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; }
|
||||
.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; }
|
||||
.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; }
|
||||
.sheet-header .expand { color: var(--muted); }
|
||||
.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; }
|
||||
.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; }
|
||||
.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); }
|
||||
.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; }
|
||||
.source-preview strong { font-size: 12px; }
|
||||
.source-preview small { color: var(--muted); font-size: 10px; }
|
||||
.editor-row { display: flex; gap: 10px; }
|
||||
.editor-main { flex: 1; min-width: 0; }
|
||||
.editor-main strong { display: block; font-size: 12px; margin: 2px 0; }
|
||||
.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; }
|
||||
.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; }
|
||||
.editor-input::placeholder { color: var(--muted); }
|
||||
.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); }
|
||||
.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); }
|
||||
.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; }
|
||||
.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; }
|
||||
.phone .submit { color: var(--on-accent); }
|
||||
.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; }
|
||||
.editor-attachment span { flex: 1; overflow-wrap: anywhere; }
|
||||
.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); }
|
||||
.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; }
|
||||
.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; }
|
||||
.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; }
|
||||
.keys .space { max-width: 150px; flex: 5; font-size: 10px; }
|
||||
.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; }
|
||||
.menu-sheet { min-height: 0; padding-bottom: 24px; }
|
||||
.menu-content { padding: 10px 20px; }
|
||||
.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); }
|
||||
.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; }
|
||||
.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; }
|
||||
.concept-notes strong { color: #33392f; font-weight: 600; }
|
||||
.swatches { display: flex; gap: 5px; margin-bottom: 12px; }
|
||||
.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; }
|
||||
.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; }
|
||||
.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; }
|
||||
.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; }
|
||||
.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; }
|
||||
.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; }
|
||||
.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; }
|
||||
.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; }
|
||||
.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; }
|
||||
/* Варианты отличаются также плотностью, иерархией и формой редактора. */
|
||||
.phone[data-theme="mono"] .post { padding-left: 18px; }
|
||||
.phone[data-theme="mono"] .post-content { margin-left: 43px; }
|
||||
.phone[data-theme="mono"] .landscape { height: 128px; }
|
||||
.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); }
|
||||
.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; }
|
||||
.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
|
||||
.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; }
|
||||
.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; }
|
||||
.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; }
|
||||
.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; }
|
||||
.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; }
|
||||
.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); }
|
||||
.phone[data-theme="night"] .composer-trigger { border-radius: 13px; }
|
||||
.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; }
|
||||
.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; }
|
||||
.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; }
|
||||
.phone[data-theme="club"] .channel-copy small { display: none; }
|
||||
.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; }
|
||||
.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; }
|
||||
.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); }
|
||||
.phone[data-theme="club"] .post { padding: 12px 16px 5px; }
|
||||
.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; }
|
||||
.phone[data-theme="club"] .landscape { height: 108px; }
|
||||
.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; }
|
||||
.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; }
|
||||
.phone[data-theme="club"] .reply { padding-top: 12px; }
|
||||
.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; }
|
||||
.phone[data-theme="club"] .composer-trigger { border-radius: 10px; }
|
||||
.gallery.single { display: block; max-width: 410px; margin: auto; }
|
||||
.single .concept-description { min-height: 0; }
|
||||
body.focused .compare-link { display: inline; }
|
||||
@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } }
|
||||
@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } }
|
||||
@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } }
|
||||
@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } }
|
||||
@@ -0,0 +1,246 @@
|
||||
/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */
|
||||
'use strict';
|
||||
const icons = {
|
||||
back: '<path d="m14 5-7 7 7 7M7 12h14"/>',
|
||||
close: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
heart: '<path d="M20.8 4.8a5.5 5.5 0 0 0-7.8 0L12 6l-1.1-1.2a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"/>',
|
||||
chat: '<path d="M21 11.5a9 9 0 0 1-9 9 9.7 9.7 0 0 1-4-.9L3 21l1.4-4.7A9 9 0 1 1 21 11.5Z"/>',
|
||||
share: '<path d="m21 3-7 18-4-8-8-3 19-7ZM10 13 21 3"/>',
|
||||
channels: '<rect x="3" y="4" width="18" height="13" rx="3"/><path d="m8 21 4-4 4 4M7 8h10M7 12h6"/>',
|
||||
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5"/>',
|
||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
|
||||
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||
book: '<path d="M12 5v16M3 4c4-1 6-1 9 1 3-2 5-2 9-1v15c-4-1-6-1-9 2-3-3-5-3-9-2Z"/>',
|
||||
file: '<path d="M14 2H5v20h14V7l-5-5ZM14 2v6h5M8 13h8M8 17h5"/>',
|
||||
attach: '<path d="m9 17 9-9a3 3 0 0 0-4-4L4 14a5 5 0 0 0 7 7L21 11M7 14l8-8"/>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
|
||||
expand: '<path d="M14 3h7v7M21 3l-7 7M10 21H3v-7M3 21l7-7"/>',
|
||||
check: '<path d="m5 12 4 4L19 6"/>',
|
||||
edit: '<path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
|
||||
wifi: '<path d="M3 8a15 15 0 0 1 18 0M6 12a10 10 0 0 1 12 0M9 16a5 5 0 0 1 6 0M12 20h.01"/>',
|
||||
battery: '<rect x="2" y="6" width="18" height="12" rx="2"/><path d="M23 10v4M5 9h11v6H5Z"/>',
|
||||
};
|
||||
const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.chat}</svg>`;
|
||||
const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c]));
|
||||
const button = (action, label, name, extra = '') => `<button class="icon-btn" data-action="${action}" aria-label="${label}" title="${label}" ${extra}>${icon(name)}</button>`;
|
||||
const concepts = [
|
||||
{ id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. <strong>Самая нейтральная отправная точка.</strong>' },
|
||||
{ id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается <strong>округлым листом снизу.</strong>' },
|
||||
{ id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. <strong>Мой кандидат на развитие:</strong> узнаваемый, но сдержанный.' },
|
||||
{ id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор <strong>на весь экран</strong>, без потери контекста.' },
|
||||
];
|
||||
const channels = [
|
||||
{ title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true },
|
||||
{ title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true },
|
||||
{ title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true },
|
||||
{ title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true },
|
||||
{ title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false },
|
||||
{ title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false },
|
||||
];
|
||||
let role = 'reader';
|
||||
let showKeyboard = false;
|
||||
const states = new Map();
|
||||
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
|
||||
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
|
||||
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
|
||||
function header(s) {
|
||||
if (s.screen === 'list') return `<header class="app-header list-header"><span class="mini-brand">✳</span><div class="header-text"><h3>Каналы</h3></div>${button('new-channel','Создать канал','plus')}</header>`;
|
||||
return `<header class="app-header">${button('back','Назад','back')}<div class="header-text"><h3>${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}</h3><small>${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}</small></div>${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}</header>`;
|
||||
}
|
||||
function nav() {
|
||||
return `<nav class="bottom-nav" aria-label="Разделы приложения">${[['chat','Личные'],['channels','Каналы'],['people','Связи'],['bell','Уведомления'],['user','Профиль']].map(([i,label])=>`<button class="nav-item" data-action="${i==='channels'?'list':'outside'}" ${i==='channels'?'aria-current="page"':''}>${icon(i)}<span>${label}</span></button>`).join('')}</nav>`;
|
||||
}
|
||||
function rows(s) {
|
||||
const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase()));
|
||||
return filtered.length ? filtered.map(c=>`<button class="channel-row" data-action="open-channel" data-index="${channels.indexOf(c)}"><span class="avatar ${c.color}">${c.symbol}</span><span class="channel-copy"><strong>${c.title}</strong><small>@${c.owner} / ${c.slug}</small><p>${c.preview}</p></span><span class="channel-tail"><span>${c.time}</span>${c.unread?`<span class="badge" aria-label="${c.unread} непрочитанных">${c.unread}</span>`:''}</span></button>`).join('') : '<p class="empty">Каналы не найдены.<br>Попробуйте другое название или логин.</p>';
|
||||
}
|
||||
function list(s) {
|
||||
return `<label class="search-field">${icon('search')}<input data-search aria-label="Поиск каналов" placeholder="Название или @автор" value="${escapeText(s.query)}"></label><div class="filter-tabs" role="group" aria-label="Фильтр каналов">${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>`<button class="filter" data-action="filter" data-value="${value}" aria-pressed="${s.filter===value}">${label}</button>`).join('')}</div><div class="section-label"><span>${s.filter==='mine'?'Ваши каналы':'Ваше пространство'}</span><span>SHiNE</span></div><div class="channel-rows">${rows(s)}</div><p class="list-tip">Хорошие разговоры начинаются<br>с общих интересов.</p>`;
|
||||
}
|
||||
function actions(s, id, count, replies) {
|
||||
const liked = s.likes.has(id);
|
||||
replies = 2 + s.replies.filter(reply => reply.post === id).length;
|
||||
return `<div class="post-actions"><button class="action" data-action="like" data-post="${id}" aria-label="Нравится" aria-pressed="${liked}">${icon('heart')}<span>${count+(liked?1:0)}</span></button><button class="action" data-action="thread" data-post="${id}" aria-label="Открыть ${replies} ответов">${icon('chat')}<span>${replies}</span></button><button class="action" data-action="share" aria-label="Поделиться">${icon('share')}</button><button class="action reply-link" data-action="reply" data-post="${id}">Ответить</button></div>`;
|
||||
}
|
||||
function post(s, second = false) {
|
||||
return `<article class="post"><div class="post-header"><span class="avatar city">АК</span><div class="author"><strong>${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}</strong><small>${second?'вчера, 19:10':'12 минут назад'}</small></div><button class="icon-btn more" data-action="post-menu" aria-label="Действия с сообщением">${icon('more')}</button></div><div class="post-content">${second?'<p>Собрала наши любимые места в один список. Добавляйте свои находки в ответах!</p><div class="attachment">'+icon('file')+'<div><strong>Места для прогулок.pdf</strong><small>PDF · 552 КБ</small></div></div>':`<p class="post-title">Маленькие открытия</p><p>${mainText}</p>${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}</div></article>`;
|
||||
}
|
||||
function feed(s) {
|
||||
return `<section class="channel-intro"><p>Замечаем красоту рядом. Делимся местами,<br>историями и маленькими открытиями.</p><div class="channel-tools"><button class="text-button" data-action="contents">${icon('book')} Оглавление</button><button class="pill" data-action="subscribe">${s.subscribed?'Вы подписаны':'Подписаться'}</button></div></section><div class="unread-line">Новые сообщения</div>${post(s)}${post(s,true)}`;
|
||||
}
|
||||
function replyRow(name, initials, color, text, target='') {
|
||||
return `<article class="reply"><span class="avatar ${color}">${initials}</span><div class="reply-body">${target?`<div class="reply-context">В ответ ${escapeText(target)}</div>`:''}<strong>${escapeText(name)}</strong> <small>· сейчас</small><p>${escapeText(text)}</p><div class="post-actions"><button class="action" data-action="reply-to" data-author="${escapeText(name)}" data-text="${escapeText(text)}">${icon('chat')} Ответить</button></div></div></article>`;
|
||||
}
|
||||
function thread(s) {
|
||||
const replies=s.replies.filter(r=>r.post===s.activePost);
|
||||
const second=s.activePost==='second';
|
||||
return `${post(s,second)}<div class="thread-heading"><strong>Ответы · ${2+replies.length}</strong><span>По порядку</span></div>${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`;
|
||||
}
|
||||
function composer(s) {
|
||||
if (s.screen==='list') return '';
|
||||
if (s.screen==='feed' && role==='reader') return '';
|
||||
return `<div class="composer-bar"><button class="composer-trigger ${s.screen==='feed'?'primary':''}" data-action="${s.screen==='feed'?'new-post':'reply'}"><span>${s.screen==='feed'?'Написать в канал':'Ответить '+s.channel.owner+'…'}</span>${icon(s.screen==='feed'?'plus':'edit')}</button></div>`;
|
||||
}
|
||||
function render(s) {
|
||||
const previousView = s.phone.querySelector('.view');
|
||||
s.scrolls ||= {};
|
||||
if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop;
|
||||
s.phone.innerHTML = `<div class="status-bar"><span>9:41</span><span class="status-icons">${icon('wifi')}${icon('battery')}</span></div>${header(s)}<div class="view">${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}</div>${composer(s)}${nav()}`;
|
||||
if(s.screen==='feed') for(const text of s.posts) {
|
||||
const item=document.createElement('article'); item.className='post';
|
||||
const author=document.createElement('strong'); author.textContent='Вы · сейчас';
|
||||
const body=document.createElement('p'); body.textContent=text; item.append(author,body);
|
||||
s.phone.querySelector('.view').append(item);
|
||||
}
|
||||
s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0;
|
||||
s.renderedScreen = s.screen;
|
||||
if (s.editor) mountEditor(s);
|
||||
}
|
||||
function keyboard() {
|
||||
return `<div class="keyboard" aria-hidden="true"><div class="keyboard-label">Макет клавиатуры · не интерактивный</div>${['йцукенгшщзх','фывапролджэ','ячсмитьбю'].map(row=>`<div class="keys">${[...row].map(c=>`<span>${c}</span>`).join('')}</div>`).join('')}<div class="keys"><span class="wide">123</span><span class="space">Русский</span><span class="wide">↵</span></div></div>`;
|
||||
}
|
||||
function mountEditor(s) {
|
||||
const mode = s.editor;
|
||||
const full = s.id==='mono'||s.id==='club'||s.expanded;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
overlay.innerHTML = `<section class="sheet ${full?'full':''}" role="dialog" aria-modal="true" aria-label="${mode==='post'?'Новое сообщение':'Ответ на сообщение'}" tabindex="-1">${full?'':'<div class="sheet-handle"></div>'}<div class="sheet-header">${button('close-editor','Закрыть и сохранить черновик','close')}<h4>${mode==='post'?'Новое сообщение':'Ответ'}</h4>${button('expand','Развернуть или свернуть редактор','expand','aria-pressed="'+full+'"')}</div><div class="editor-scroll">${mode==='post'?'':`<div class="source-preview"><span class="avatar city">${s.replyTo===s.channel.owner?'АК':escapeText(s.replyTo.slice(0,2).toUpperCase())}</span><div><strong>${escapeText(s.replyTo)}</strong><small> · ${escapeText(s.channel.title)}</small><p>${escapeText(s.replyText)}</p></div></div>`}<div class="editor-row"><span class="avatar self">ВЫ</span><div class="editor-main"><strong>Вы</strong><small>${mode==='post'?'Публикация в «'+escapeText(s.channel.title)+'»':'Ответ '+escapeText(s.replyTo)+' · виден в обсуждении'}</small><textarea class="editor-input" maxlength="2000" aria-label="Текст ${mode==='post'?'сообщения':'ответа'}" placeholder="${mode==='post'?'О чём хотите рассказать?':'Продолжите разговор…'}">${escapeText(s.drafts[mode])}</textarea><div class="selected-attachment">${attachmentMarkup(s)}</div></div></div></div><div class="editor-footer">${button('attach','Прикрепить файл','attach')}<input type="file" hidden data-file><span class="char-count">${s.drafts[mode].length} / 2000</span><button class="submit" data-action="send" ${!s.drafts[mode].trim()&&!s.attachments[mode]?'disabled':''}>${mode==='post'?'Опубликовать':'Ответить'}</button></div>${showKeyboard?keyboard():''}</section>`;
|
||||
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true);
|
||||
s.phone.append(overlay);
|
||||
overlay.querySelector('.sheet').focus({preventScroll:true});
|
||||
}
|
||||
function attachmentMarkup(s) {
|
||||
const name = s.attachments[s.editor];
|
||||
return name?`<div class="editor-attachment">${icon('file')}<span>${escapeText(name)}</span>${button('remove-attachment','Удалить вложение','close')}</div>`:'';
|
||||
}
|
||||
function removeOverlay(s) {
|
||||
s.phone.querySelector('.overlay')?.remove();
|
||||
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false);
|
||||
}
|
||||
function closeEditor(s) {
|
||||
s.editor = null;
|
||||
removeOverlay(s);
|
||||
if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true});
|
||||
else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true});
|
||||
}
|
||||
function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) {
|
||||
if (s.editor) closeEditor(s);
|
||||
s.editor=mode;
|
||||
s.replyTo=author;
|
||||
s.replyText=text;
|
||||
s.returnFocus=trigger;
|
||||
s.expanded=false;
|
||||
mountEditor(s);
|
||||
}
|
||||
function toast(s, message) {
|
||||
s.phone.querySelector('.toast')?.remove();
|
||||
clearTimeout(s.toastTimer);
|
||||
const el=document.createElement('div');
|
||||
el.className='toast'; el.setAttribute('role','status'); el.textContent=message;
|
||||
s.phone.append(el);
|
||||
s.toastTimer=setTimeout(()=>el.remove(),3500);
|
||||
}
|
||||
function menu(s, title, contents, trigger) {
|
||||
removeOverlay(s);
|
||||
s.returnFocus=trigger;
|
||||
const el=document.createElement('div'); el.className='overlay';
|
||||
el.innerHTML=`<section class="sheet menu-sheet" role="dialog" aria-modal="true" aria-label="${escapeText(title)}" tabindex="-1"><div class="sheet-handle"></div><div class="sheet-header">${button('close-menu','Закрыть','close')}<h4>${escapeText(title)}</h4><span style="width:44px"></span></div><div class="menu-content">${contents}</div></section>`;
|
||||
s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true);
|
||||
s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true});
|
||||
}
|
||||
const menuAction=(action,label,i='file')=>`<button class="menu-action" data-action="${action}">${icon(i)}${label}</button>`;
|
||||
function updateEditor(s) {
|
||||
const modal=s.phone.querySelector('.overlay');
|
||||
modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`;
|
||||
modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor];
|
||||
modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s);
|
||||
}
|
||||
function handle(s, event) {
|
||||
const el=event.target.closest('[data-action]');
|
||||
if (!el || el.disabled) return;
|
||||
const action=el.dataset.action;
|
||||
if (action==='filter') { s.filter=el.dataset.value; render(s); }
|
||||
else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); }
|
||||
else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); }
|
||||
else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); }
|
||||
else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); }
|
||||
else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el);
|
||||
else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); }
|
||||
else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); }
|
||||
else if (action==='attach') s.phone.querySelector('[data-file]').click();
|
||||
else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); }
|
||||
else if (action==='send') {
|
||||
const mode=s.editor;
|
||||
const text=s.drafts[mode].trim();
|
||||
const attachment=s.attachments[mode];
|
||||
if (!text&&!attachment) return;
|
||||
if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost});
|
||||
else s.posts.push(text+(attachment?'\n📎 '+attachment:''));
|
||||
s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s);
|
||||
const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight;
|
||||
s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.');
|
||||
}
|
||||
else if (action==='like') {
|
||||
const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key);
|
||||
el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1));
|
||||
}
|
||||
else if (action==='share') menu(s,'Поделиться', '<p>В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.</p>'+menuAction('close-menu','Понятно','check'),el);
|
||||
else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; }
|
||||
else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); }
|
||||
else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'<p>Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.</p>',el);
|
||||
else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el);
|
||||
else if (action==='contents') menu(s,'Оглавление','<p>С чего начать знакомство с каналом</p>'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el);
|
||||
else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); }
|
||||
else if (action==='reactions') menu(s,'Лайки','<p>Все: 24 · основные: 18 · сияющие: 6</p><p>Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.</p>',el);
|
||||
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
|
||||
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
|
||||
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
|
||||
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
|
||||
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
|
||||
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
|
||||
}
|
||||
function attachEvents(s) {
|
||||
s.phone.addEventListener('click',event=>handle(s,event));
|
||||
s.phone.addEventListener('input',event=>{
|
||||
if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); }
|
||||
if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); }
|
||||
});
|
||||
s.phone.addEventListener('change',event=>{
|
||||
if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); }
|
||||
});
|
||||
s.phone.addEventListener('keydown',event=>{
|
||||
const dialog=s.phone.querySelector('[role="dialog"]');
|
||||
if(!dialog) return;
|
||||
if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
|
||||
if(event.key==='Tab') {
|
||||
const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')];
|
||||
const first=focusable[0], last=focusable.at(-1);
|
||||
if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); }
|
||||
else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
function init() {
|
||||
const query=new URLSearchParams(location.search);
|
||||
const chosen=concepts.find(c=>c.id===query.get('variant'));
|
||||
const gallery=document.getElementById('gallery');
|
||||
if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');}
|
||||
for(const c of chosen?[chosen]:concepts) {
|
||||
const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title);
|
||||
article.innerHTML=`<div class="concept-heading"><span class="concept-number">${c.number}</span><h2>${c.title}</h2><a class="open-concept" href="?variant=${c.id}" aria-label="Открыть отдельно: ${c.title}">↗</a></div><p class="concept-description">${c.description}</p><div class="phone" data-theme="${c.id}" aria-label="Макет: ${c.title}"></div><div class="concept-notes"><div class="swatches" aria-hidden="true">${c.colors.map(color=>`<span style="background:${color}"></span>`).join('')}</div><span class="concept-tag">${c.tag}</span><div>${c.note}</div></div>`;
|
||||
gallery.append(article);
|
||||
const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null};
|
||||
states.set(c.id,s);render(s);attachEvents(s);
|
||||
}
|
||||
document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{
|
||||
document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));});
|
||||
for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); }
|
||||
el.focus({preventScroll:true});
|
||||
}));
|
||||
document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);});
|
||||
document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});});
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,314 @@
|
||||
// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs
|
||||
// Зависимости проверки (не приложения): jsdom, postcss.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs'));
|
||||
const { JSDOM } = require('jsdom');
|
||||
const postcss = require('postcss');
|
||||
const ui = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dom = new JSDOM('<button id="opener">Ответить</button><main id="app-screen"></main><div id="modal-root"></div>', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' });
|
||||
const context = dom.getInternalVMContext();
|
||||
const { window } = dom;
|
||||
const { document } = window;
|
||||
const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} };
|
||||
let placed = 0;
|
||||
let authPrompts = 0;
|
||||
const mocks = {
|
||||
'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) },
|
||||
'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text },
|
||||
'state.js': { state },
|
||||
'ui-error-texts.js': { toUserMessage: (error) => error.message },
|
||||
'avatar-image.js': { renderUserAvatar: () => document.createElement('span') },
|
||||
'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ },
|
||||
};
|
||||
async function moduleAt(relative, mockImports = false) {
|
||||
const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context });
|
||||
await module.link(async (specifier) => {
|
||||
const values = mockImports && mocks[path.basename(specifier)];
|
||||
assert.ok(values, `Неожиданный импорт ${specifier}`);
|
||||
return new vm.SyntheticModule(Object.keys(values), function () {
|
||||
for (const [key, value] of Object.entries(values)) this.setExport(key, value);
|
||||
}, { context });
|
||||
});
|
||||
await module.evaluate();
|
||||
return module.namespace;
|
||||
}
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 35));
|
||||
const query = (selector) => document.querySelector(selector);
|
||||
function input(value) {
|
||||
const field = query('textarea');
|
||||
field.value = value;
|
||||
field.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
}
|
||||
function key(key, options = {}) {
|
||||
const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options });
|
||||
document.activeElement.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true);
|
||||
let sent = [];
|
||||
const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) };
|
||||
query('#opener').focus();
|
||||
let editor = openChannelEditor(options);
|
||||
await tick();
|
||||
assert.equal(document.activeElement.tagName, 'TEXTAREA');
|
||||
assert.equal(query('.channel-editor__submit').disabled, true);
|
||||
input('Первая строка\nВторая строка');
|
||||
assert.equal(key('Enter').defaultPrevented, false);
|
||||
assert.equal(sent.length, 0);
|
||||
editor.close();
|
||||
await tick();
|
||||
assert.equal(document.activeElement.id, 'opener');
|
||||
editor = openChannelEditor(options);
|
||||
await tick();
|
||||
assert.equal(query('textarea').value, 'Первая строка\nВторая строка');
|
||||
key('Enter', { ctrlKey: true, isComposing: true });
|
||||
assert.equal(sent.length, 0);
|
||||
key('Enter', { ctrlKey: true });
|
||||
await tick();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(query('.channel-editor-overlay'), null);
|
||||
editor = openChannelEditor(options);
|
||||
assert.equal(query('textarea').value, '');
|
||||
editor.close();
|
||||
await tick();
|
||||
|
||||
editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } });
|
||||
await tick();
|
||||
input('Не потерять');
|
||||
query('.channel-editor__submit').click();
|
||||
await tick();
|
||||
assert.equal(query('[role="alert"]').textContent, 'Нет соединения');
|
||||
assert.equal(query('textarea').value, 'Не потерять');
|
||||
assert.equal(query('.channel-editor__submit').disabled, false);
|
||||
query('.channel-editor__close').focus();
|
||||
key('Tab', { shiftKey: true });
|
||||
assert.equal(document.activeElement, query('.channel-editor__submit'));
|
||||
editor.close();
|
||||
await tick();
|
||||
state.session.login = 'bob';
|
||||
editor = openChannelEditor(options);
|
||||
assert.equal(query('textarea').value, '');
|
||||
editor.close();
|
||||
await tick();
|
||||
state.session.login = 'alice';
|
||||
editor = openChannelEditor(options);
|
||||
assert.equal(query('textarea').value, 'Не потерять');
|
||||
query('.channel-editor__clear').click();
|
||||
assert.equal(query('textarea').value, '');
|
||||
query('.channel-editor__attach').click();
|
||||
await tick();
|
||||
assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1);
|
||||
assert.equal(query('.channel-editor__submit').disabled, false);
|
||||
query('.channel-editor__submit').click();
|
||||
await tick();
|
||||
assert.equal(placed, 1);
|
||||
editor = openChannelEditor(options);
|
||||
await tick();
|
||||
window.history.back();
|
||||
await tick();
|
||||
assert.equal(query('.channel-editor-overlay'), null);
|
||||
editor.close();
|
||||
state.session.isAuthorized = false;
|
||||
assert.equal(openChannelEditor(options), null);
|
||||
assert.equal(authPrompts, 1);
|
||||
|
||||
const theme = await moduleAt('js/services/theme-service.js');
|
||||
assert.equal(theme.applyThemeMode('system').resolved, 'dark');
|
||||
theme.setThemeMode('light');
|
||||
assert.equal(document.documentElement.dataset.theme, 'light');
|
||||
theme.setThemeMode('dark');
|
||||
assert.equal(document.documentElement.dataset.theme, 'dark');
|
||||
const scroll = await moduleAt('js/services/channel-view-state.js');
|
||||
query('#app-screen').scrollTop = 123;
|
||||
scroll.rememberChannelPosition('alice:channel:one');
|
||||
query('#app-screen').scrollTop = 0;
|
||||
scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one'));
|
||||
assert.equal(query('#app-screen').scrollTop, 123);
|
||||
assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined);
|
||||
|
||||
const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js');
|
||||
const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] });
|
||||
menu.open();
|
||||
assert.equal(document.activeElement.textContent, 'Первый');
|
||||
key('ArrowDown');
|
||||
assert.equal(document.activeElement.textContent, 'Второй');
|
||||
key('Escape');
|
||||
assert.equal(document.activeElement.id, 'opener');
|
||||
menu.destroy();
|
||||
menu.destroy();
|
||||
assert.equal(query('.dropdown-portal'), null);
|
||||
console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.');
|
||||
|
||||
// DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн.
|
||||
let replyOptions;
|
||||
const reactionState = new Map();
|
||||
const iconModule = await moduleAt('js/components/ui-icon.js');
|
||||
const pageValues = {
|
||||
state, authService: {},
|
||||
channels: [],
|
||||
readChannelNotificationsState: () => ({}),
|
||||
createSkeletonCard: () => document.createElement('div'),
|
||||
createTopBar: ({ center }) => {
|
||||
const header = document.createElement('header');
|
||||
if (center) header.append(center);
|
||||
return header;
|
||||
},
|
||||
iconHtml: iconModule.iconHtml,
|
||||
parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }),
|
||||
parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }),
|
||||
loadProfileSnapshot: async () => null,
|
||||
renderAvatar: () => document.createElement('span'),
|
||||
renderUserAvatar: () => document.createElement('span'),
|
||||
formatRelativeTime: () => 'сейчас',
|
||||
escapeHtml: (text) => String(text || ''),
|
||||
openChannelEditor: (options) => { replyOptions = options; },
|
||||
getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked',
|
||||
setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value),
|
||||
makeShineMessageRoute: () => 'thread:test',
|
||||
attachMessageMenu: (card, head, items) => {
|
||||
const button = document.createElement('button');
|
||||
head.append(button);
|
||||
card.testMenu = items;
|
||||
const menu = createDropdownMenu({ anchorEl: button, items });
|
||||
card.cleanup = () => menu.destroy();
|
||||
},
|
||||
};
|
||||
async function pageProducer(file, exported) {
|
||||
const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8');
|
||||
const imports = new Map();
|
||||
for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
|
||||
imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]);
|
||||
}
|
||||
const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context });
|
||||
await module.link(async (specifier) => {
|
||||
const names = imports.get(specifier);
|
||||
assert.ok(names, specifier);
|
||||
return new vm.SyntheticModule(names, function () {
|
||||
names.forEach((name) => this.setExport(name, pageValues[name] || (() => {})));
|
||||
}, { context });
|
||||
});
|
||||
await module.evaluate();
|
||||
return module.namespace[exported];
|
||||
}
|
||||
state.session.isAuthorized = true;
|
||||
state.session.login = 'alice';
|
||||
const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) };
|
||||
let likes = 0;
|
||||
let navigations = 0;
|
||||
const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true };
|
||||
const renderPost = await pageProducer('channel-view.js', 'renderPostCard');
|
||||
const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers);
|
||||
document.body.append(post);
|
||||
assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация');
|
||||
assert.equal(post.querySelectorAll('.channel-action-counter').length, 3);
|
||||
assert.ok(post.testMenu.some((item) => item.label === 'Редактировать'));
|
||||
assert.ok(post.testMenu.some((item) => item.label === 'Удалить'));
|
||||
post.querySelector('.channel-action-like').click();
|
||||
await tick();
|
||||
assert.equal(likes, 1);
|
||||
assert.equal(post.querySelector('.channel-action-like').disabled, false);
|
||||
post.querySelector('.channel-action-reply').click();
|
||||
assert.equal(replyOptions.context.author, 'alice');
|
||||
assert.equal(replyOptions.context.text, 'Публикация');
|
||||
assert.equal(navigations, 0);
|
||||
post.cleanup(); post.cleanup(); post.remove();
|
||||
const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard');
|
||||
const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2);
|
||||
document.body.append(node);
|
||||
assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ');
|
||||
assert.ok(!node.testMenu.some((item) => item.label === 'Удалить'));
|
||||
node.querySelector('.thread-reply-btn').click();
|
||||
assert.equal(replyOptions.context.author, 'bob');
|
||||
node.cleanup(); node.cleanup(); node.remove();
|
||||
console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.');
|
||||
|
||||
pageValues.authService.onEvent = () => () => {};
|
||||
pageValues.toUserMessage = (error) => error.message;
|
||||
pageValues.rememberChannelPosition = scroll.rememberChannelPosition;
|
||||
pageValues.readChannelPosition = scroll.readChannelPosition;
|
||||
pageValues.restoreChannelPosition = scroll.restoreChannelPosition;
|
||||
state.channelsFeed = {};
|
||||
state.channelIndex = {};
|
||||
const chrome = { setTopbar() {}, setComposer() {} };
|
||||
for (const [file, method, params, payload] of [
|
||||
['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }],
|
||||
['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }],
|
||||
['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }],
|
||||
]) {
|
||||
let finish;
|
||||
pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; });
|
||||
const render = await pageProducer(file, 'render');
|
||||
const screen = render({ route: { params }, navigate() {}, chrome });
|
||||
query('#app-screen').append(screen);
|
||||
await tick();
|
||||
assert.ok(finish, `${file}: запрос начат`);
|
||||
screen.cleanup();
|
||||
screen.cleanup();
|
||||
const markup = screen.innerHTML;
|
||||
finish(payload);
|
||||
await tick();
|
||||
assert.equal(screen.innerHTML, markup, `${file}: async после dispose`);
|
||||
assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`);
|
||||
screen.remove();
|
||||
pageValues.authService[method] = async () => payload;
|
||||
const loaded = render({ route: { params }, navigate() {}, chrome });
|
||||
query('#app-screen').append(loaded);
|
||||
await tick();
|
||||
const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block';
|
||||
assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`);
|
||||
if (loaded.refresh) await loaded.refresh();
|
||||
assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`);
|
||||
loaded.cleanup(); loaded.remove();
|
||||
}
|
||||
console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.');
|
||||
|
||||
function walk(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]);
|
||||
}
|
||||
const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js'));
|
||||
for (const file of scripts) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) {
|
||||
assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`);
|
||||
}
|
||||
}
|
||||
const repo = path.dirname(ui);
|
||||
const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n');
|
||||
let cssCount = 0;
|
||||
for (const relative of changed) {
|
||||
const file = path.join(repo, relative);
|
||||
if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) });
|
||||
if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue;
|
||||
cssCount++;
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const tree = postcss.parse(source, { from: file });
|
||||
let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
|
||||
const baselineRules = new Map();
|
||||
postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1));
|
||||
const rules = new Map();
|
||||
const duplicates = [];
|
||||
tree.walkRules((rule) => {
|
||||
const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params);
|
||||
const key = parents.join('/') + ':' + rule.selector;
|
||||
const count = (rules.get(key) || 0) + 1;
|
||||
rules.set(key, count);
|
||||
if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key);
|
||||
});
|
||||
assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`);
|
||||
assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`);
|
||||
}
|
||||
const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8');
|
||||
const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]);
|
||||
assert.equal(new Set(cssManifest).size, cssManifest.length);
|
||||
for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`);
|
||||
assert.ok(cssManifest.includes('styles/components/channel-editor.css'));
|
||||
console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`);
|
||||
dom.window.close();
|
||||
+29
-2
@@ -4,21 +4,48 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
/>
|
||||
<meta name="theme-color" content="#12141f" />
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
(function applySavedThemeBeforePaint() {
|
||||
let mode = 'system';
|
||||
try {
|
||||
const saved = localStorage.getItem('shine-ui-theme-mode-v1');
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'system') mode = saved;
|
||||
} catch {}
|
||||
const resolved = mode === 'system'
|
||||
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
||||
: mode;
|
||||
document.documentElement.dataset.themeMode = mode;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
// Своя палитра (см. js/services/theme-service.js) применяется до отрисовки, без вспышки.
|
||||
try {
|
||||
const applied = JSON.parse(localStorage.getItem('shine-ui-palette-applied-v1') || 'null');
|
||||
const colors = applied && applied[resolved];
|
||||
if (colors) {
|
||||
Object.keys(colors).forEach(function (role) {
|
||||
if (/^#[0-9a-f]{6}$/i.test(colors[role])) document.documentElement.style.setProperty('--' + role, colors[role]);
|
||||
});
|
||||
if (colors.accent) document.documentElement.style.setProperty('--focus-ring', colors.accent);
|
||||
}
|
||||
} catch {}
|
||||
}());
|
||||
</script>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
+24
-18
@@ -52,37 +52,38 @@ import {
|
||||
setContacts,
|
||||
} from './state.js';
|
||||
|
||||
import * as startView from './pages/start-view.js?v=202606142105';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||
import * as registerView from './pages/register-view.js?v=202606201650';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
|
||||
import * as startView from './pages/start-view.js?v=202609260900';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202609260900';
|
||||
import * as registerView from './pages/register-view.js?v=202609260900';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||
import * as topupView from './pages/topup-view.js';
|
||||
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
||||
import * as loginView from './pages/login-view.js?v=202606150110';
|
||||
import * as loginView from './pages/login-view.js?v=202609260900';
|
||||
import * as loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileView from './pages/profile-view.js?v=202609260900';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202609260900';
|
||||
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as deviceView from './pages/device-view.js?v=202609260900';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202609260900';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202609260900';
|
||||
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
|
||||
import * as deviceQrView from './pages/device-qr-view.js';
|
||||
import * as deviceCameraView from './pages/device-camera-view.js';
|
||||
import * as showKeysView from './pages/show-keys-view.js';
|
||||
@@ -92,21 +93,21 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as messagesList from './pages/messages-list.js?v=202609260900';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as chatView from './pages/chat-view.js?v=202609260900';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as userProfileListView from './pages/user-profile-list-view.js';
|
||||
import * as userRelationManageView from './pages/user-relation-manage-view.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelsList from './pages/channels-list.js?v=202609260900';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
import * as channelDonateView from './pages/channel-donate-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
import * as networkView from './pages/network-view.js?v=202609260900';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202609260900';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -404,6 +405,7 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
}
|
||||
|
||||
const MANAGED_SHELL_CLASSES = [
|
||||
'app-shell--wide',
|
||||
'app-shell--top-fade',
|
||||
'app-shell--bottom-fade',
|
||||
'app-shell--bottom-fade-composer',
|
||||
@@ -455,6 +457,7 @@ function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||
function applyShellMode(mode, showAppChrome = true) {
|
||||
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
|
||||
const normalized = normalizeShellMode(mode, showAppChrome);
|
||||
appShellEl.classList.toggle('app-shell--wide', normalized.contentWidth === 'wide');
|
||||
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
|
||||
@@ -1414,7 +1417,8 @@ function renderApp() {
|
||||
try {
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
// В переписке нижняя панель прячется (pageMeta.hideToolbar), чтобы не отнимать место у сообщений.
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
@@ -1446,7 +1450,7 @@ function refreshToolbarOnly() {
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
@@ -1532,6 +1536,8 @@ async function ensureSessionRuntimeStarted() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
applyThemeMode();
|
||||
watchSystemTheme();
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
@@ -300,6 +300,7 @@ export function openArweaveAttachmentManager({
|
||||
autoOpenFileDialog = true,
|
||||
shineType = '',
|
||||
extraUploadTags = [],
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -359,6 +360,7 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||
if (closed) return;
|
||||
const item = persistToHistory
|
||||
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
@@ -373,6 +375,9 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = () => close(resolve, null);
|
||||
if (signal?.aborted) { onAbort(); return; }
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const bindBackdrop = () => {
|
||||
const modal = root.querySelector('[data-ar-attach-modal="true"]');
|
||||
modal?.addEventListener('click', (event) => {
|
||||
@@ -429,20 +434,23 @@ export function openArweaveAttachmentManager({
|
||||
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
|
||||
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
|
||||
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
|
||||
const freeHint = selectedFile
|
||||
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
|
||||
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
|
||||
const freeBadge = priceInfo?.isFree
|
||||
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
|
||||
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
|
||||
const summary = selectedFile
|
||||
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
|
||||
? '<span class="ar-attachment-ok">бесплатно через Turbo</span>'
|
||||
: `нужен баланс Turbo: ${escapeHtml(uploadPrice)}`}`
|
||||
: '';
|
||||
mainMetaEl.innerHTML = `
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>Размер файла: ${escapeHtml(fileSize)}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
|
||||
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
|
||||
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
|
||||
<details class="ar-attachment-details">
|
||||
<summary>Подробнее</summary>
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
|
||||
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
|
||||
</details>
|
||||
`;
|
||||
previewMetaEl.innerHTML = '';
|
||||
if (selectedPreviewEnabled && selectedPreviewFile) {
|
||||
@@ -623,28 +631,40 @@ export function openArweaveAttachmentManager({
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
<div class="ar-attachment-manager-head">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
|
||||
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
|
||||
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
<p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
|
||||
<div class="form-field">
|
||||
<label for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
<div class="form-field">
|
||||
<label for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="select ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="text-btn ar-attachment-link" type="button" data-action="add-wallet">+ Добавить кошелёк</button>
|
||||
</div>
|
||||
${isAvatarMode ? '<p class="ar-attachment-note">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="ar-attachment-note">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
`}
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
${canShowHistory || canShowExisting ? `
|
||||
<div class="ar-attachment-alt-sources">
|
||||
${canShowHistory ? `<button class="text-btn ar-attachment-link" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Из журнала загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="text-btn ar-attachment-link" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Уже есть в Arweave (tx id)'}</button>` : ''}
|
||||
</div>` : ''}
|
||||
${fixedFile ? '' : `<label class="ar-attachment-file-picker">
|
||||
<input class="ar-attachment-file-input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
<span class="ar-attachment-file-btn">Выбрать файл</span>
|
||||
<span class="ar-attachment-file-name" data-file-name="true">Файл не выбран</span>
|
||||
</label>`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -653,10 +673,9 @@ export function openArweaveAttachmentManager({
|
||||
<div class="ar-attachment-meta" data-meta-preview="true"></div>
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : '<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>'}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -729,7 +748,7 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
}
|
||||
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
|
||||
root.querySelectorAll('[data-action="cancel"]').forEach((btn) => btn.addEventListener('click', () => close(resolve, null)));
|
||||
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
|
||||
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
|
||||
if (turboMode && selectedFile && selectedSha256) {
|
||||
@@ -740,6 +759,10 @@ export function openArweaveAttachmentManager({
|
||||
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
|
||||
}
|
||||
|
||||
fileEl?.addEventListener('change', () => {
|
||||
const nameEl = root.querySelector('[data-file-name="true"]');
|
||||
if (nameEl) nameEl.textContent = fileEl.files?.[0]?.name || 'Файл не выбран';
|
||||
});
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
selectedFile = fileEl.files?.[0] || null;
|
||||
selectedSha256 = '';
|
||||
@@ -1036,7 +1059,7 @@ export function openArweaveAttachmentManager({
|
||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
|
||||
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Ещё не прикреплён</span>' : ''}
|
||||
</div>
|
||||
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
|
||||
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
|
||||
import { state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { renderUserAvatar } from './avatar-image.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
|
||||
const drafts = new Map();
|
||||
|
||||
function draftKey(key) {
|
||||
const login = String(state.session.login || 'guest').trim().toLowerCase();
|
||||
return `${login}:${String(key || 'editor')}`;
|
||||
}
|
||||
|
||||
function attachmentLabel(item) {
|
||||
const name = String(item?.name || 'Файл');
|
||||
const size = Number(item?.size || 0);
|
||||
if (!size) return name;
|
||||
if (size < 1024) return `${name} · ${size} Б`;
|
||||
if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`;
|
||||
return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
export function openChannelEditor({
|
||||
id = 'channel-editor',
|
||||
title = 'Ответ',
|
||||
submitLabel = 'Ответить',
|
||||
placeholder = 'Напишите ответ',
|
||||
context = null,
|
||||
key = 'reply',
|
||||
extraControl = null,
|
||||
initialText = '',
|
||||
allowEmptyText = false,
|
||||
allowAttachments = true,
|
||||
rawText = false,
|
||||
onSubmit,
|
||||
isActive = () => true,
|
||||
} = {}) {
|
||||
if (!state.session.isAuthorized) {
|
||||
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
|
||||
return null;
|
||||
}
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || typeof onSubmit !== 'function') return null;
|
||||
if (root.querySelector('.channel-editor-overlay')) return null;
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const storageKey = draftKey(key);
|
||||
const login = state.session.login;
|
||||
const saved = drafts.get(storageKey) || { text: initialText, attachments: [] };
|
||||
const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : [];
|
||||
const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : [];
|
||||
extraFields.forEach((field, index) => {
|
||||
if (saved.controls?.[index] !== undefined) field.value = saved.controls[index];
|
||||
});
|
||||
let inFlight = false;
|
||||
let composing = false;
|
||||
let closed = false;
|
||||
let picking = false;
|
||||
let completed = false;
|
||||
const attachmentController = new AbortController();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'channel-editor-overlay';
|
||||
overlay.id = id;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-labelledby', `${id}-title`);
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'channel-editor';
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'channel-editor__header';
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'icon-btn channel-editor__close';
|
||||
closeButton.setAttribute('aria-label', 'Закрыть редактор');
|
||||
closeButton.textContent = '×';
|
||||
const heading = document.createElement('h2');
|
||||
heading.id = `${id}-title`;
|
||||
heading.textContent = title;
|
||||
header.append(closeButton, heading, document.createElement('span'));
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'channel-editor__body';
|
||||
|
||||
if (context && (context.author || context.text || context.attachmentLabel)) {
|
||||
const contextBox = document.createElement('div');
|
||||
contextBox.className = 'channel-editor__context';
|
||||
const contextTitle = document.createElement('strong');
|
||||
contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения';
|
||||
const quote = document.createElement('p');
|
||||
quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста');
|
||||
const expand = document.createElement('button');
|
||||
expand.type = 'button';
|
||||
expand.className = 'text-btn channel-editor__context-toggle';
|
||||
expand.textContent = 'Показать целиком';
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
expand.addEventListener('click', () => {
|
||||
const expanded = contextBox.classList.toggle('is-expanded');
|
||||
expand.textContent = expanded ? 'Свернуть' : 'Показать целиком';
|
||||
expand.setAttribute('aria-expanded', String(expanded));
|
||||
});
|
||||
contextBox.append(contextTitle, quote, expand);
|
||||
body.append(contextBox);
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'channel-editor__input';
|
||||
textarea.maxLength = 2000;
|
||||
textarea.placeholder = placeholder;
|
||||
textarea.setAttribute('aria-label', placeholder);
|
||||
textarea.value = String(saved.text || '');
|
||||
|
||||
const attachmentsBox = document.createElement('div');
|
||||
attachmentsBox.className = 'channel-editor__attachments';
|
||||
attachmentsBox.setAttribute('aria-live', 'polite');
|
||||
|
||||
const error = document.createElement('p');
|
||||
error.className = 'channel-editor__error';
|
||||
error.setAttribute('role', 'alert');
|
||||
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'text-btn channel-editor__clear';
|
||||
clearButton.textContent = 'Очистить черновик';
|
||||
|
||||
const author = document.createElement('div');
|
||||
author.className = 'channel-editor__author';
|
||||
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
|
||||
const authorName = document.createElement('strong');
|
||||
authorName.textContent = login || 'Гость';
|
||||
author.append(authorName);
|
||||
body.append(author);
|
||||
body.append(textarea, attachmentsBox, error, clearButton);
|
||||
|
||||
const footer = document.createElement('footer');
|
||||
footer.className = 'channel-editor__footer';
|
||||
const attachButton = document.createElement('button');
|
||||
attachButton.type = 'button';
|
||||
attachButton.className = 'secondary-btn channel-editor__attach';
|
||||
attachButton.innerHTML = `${iconHtml('clip')}<span class="sr-only">Прикрепить</span>`;
|
||||
attachButton.title = 'Прикрепить файл';
|
||||
attachButton.hidden = !allowAttachments;
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'channel-editor__counter';
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.type = 'button';
|
||||
submitButton.className = 'primary-btn channel-editor__submit';
|
||||
submitButton.textContent = submitLabel;
|
||||
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
|
||||
footer.append(attachButton);
|
||||
if (extraControl instanceof Node) {
|
||||
footer.classList.add('has-extra');
|
||||
footer.append(extraControl);
|
||||
}
|
||||
footer.append(counter, submitButton);
|
||||
dialog.append(header, body, footer);
|
||||
overlay.append(dialog);
|
||||
root.replaceChildren(overlay);
|
||||
|
||||
const saveDraft = () => {
|
||||
const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) };
|
||||
if (completed) { drafts.delete(storageKey); return; }
|
||||
if (value.text || value.attachments.length) drafts.set(storageKey, value);
|
||||
else drafts.delete(storageKey);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`;
|
||||
const length = textarea.value.length;
|
||||
counter.textContent = `${length} / 2000`;
|
||||
counter.classList.toggle('is-near-limit', length >= 1800);
|
||||
submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0);
|
||||
dialog.setAttribute('aria-busy', String(inFlight || picking));
|
||||
clearButton.hidden = !textarea.value && attachments.length === 0;
|
||||
attachmentsBox.replaceChildren();
|
||||
attachments.forEach((item, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'channel-editor__attachment';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = attachmentLabel(item);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'icon-btn';
|
||||
remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`);
|
||||
remove.textContent = '×';
|
||||
remove.disabled = inFlight;
|
||||
remove.addEventListener('click', () => {
|
||||
attachments.splice(index, 1);
|
||||
saveDraft();
|
||||
sync();
|
||||
});
|
||||
card.append(label, remove);
|
||||
attachmentsBox.append(card);
|
||||
});
|
||||
};
|
||||
|
||||
const openedUrl = location.href;
|
||||
const historyId = `${Date.now()}:${Math.random()}`;
|
||||
history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl);
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewport = () => {
|
||||
overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`);
|
||||
overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`);
|
||||
};
|
||||
const onBack = (event) => {
|
||||
if (closed) return;
|
||||
event.stopImmediatePropagation();
|
||||
close({ fromHistory: true });
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!overlay.isConnected) close({ restoreFocus: false });
|
||||
});
|
||||
const close = ({ fromHistory = false, restoreFocus = true } = {}) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
saveDraft();
|
||||
observer.disconnect();
|
||||
attachmentController.abort();
|
||||
document.removeEventListener('keydown', onDocumentKeydown);
|
||||
viewport?.removeEventListener('resize', updateViewport);
|
||||
viewport?.removeEventListener('scroll', updateViewport);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
window.removeEventListener('popstate', onBack, true);
|
||||
overlay.remove();
|
||||
if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) {
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (location.href === openedUrl) event.stopImmediatePropagation();
|
||||
}, { capture: true, once: true });
|
||||
history.back();
|
||||
}
|
||||
if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true });
|
||||
};
|
||||
overlay.cleanup = () => close({ restoreFocus: false });
|
||||
|
||||
const submit = async () => {
|
||||
if (inFlight || submitButton.disabled) return;
|
||||
inFlight = true;
|
||||
error.textContent = '';
|
||||
textarea.disabled = true;
|
||||
attachButton.disabled = true;
|
||||
clearButton.disabled = true;
|
||||
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; });
|
||||
submitButton.textContent = 'Отправляем…';
|
||||
sync();
|
||||
try {
|
||||
await onSubmit({
|
||||
text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments),
|
||||
attachments: [...attachments],
|
||||
});
|
||||
completed = true;
|
||||
attachments.forEach((item) => markArweaveAttachmentPlaced(login, item));
|
||||
// Не удаляем новый черновик, открытый после закрытия отправляющего редактора.
|
||||
if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey);
|
||||
if (!isActive() || closed) return;
|
||||
close();
|
||||
} catch (submitError) {
|
||||
if (!isActive() || closed) return;
|
||||
inFlight = false;
|
||||
textarea.disabled = false;
|
||||
attachButton.disabled = false;
|
||||
clearButton.disabled = false;
|
||||
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; });
|
||||
submitButton.textContent = submitLabel;
|
||||
error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.');
|
||||
saveDraft();
|
||||
sync();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentKeydown = (event) => {
|
||||
if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', () => close());
|
||||
clearButton.addEventListener('click', () => {
|
||||
textarea.value = '';
|
||||
attachments.splice(0);
|
||||
drafts.delete(storageKey);
|
||||
error.textContent = '';
|
||||
sync();
|
||||
textarea.focus();
|
||||
});
|
||||
textarea.addEventListener('input', () => {
|
||||
error.textContent = '';
|
||||
saveDraft();
|
||||
sync();
|
||||
});
|
||||
textarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
extraFields.forEach((field) => field.addEventListener('change', saveDraft));
|
||||
textarea.addEventListener('compositionend', () => { composing = false; });
|
||||
attachButton.addEventListener('click', async () => {
|
||||
if (picking || inFlight) return;
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
picking = true;
|
||||
dialog.inert = true;
|
||||
sync();
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
signal: attachmentController.signal,
|
||||
});
|
||||
if (!isActive() || !overlay.isConnected || !item) return;
|
||||
attachments.push(item);
|
||||
saveDraft();
|
||||
sync();
|
||||
} catch (attachError) {
|
||||
if (isActive() && overlay.isConnected) {
|
||||
error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.');
|
||||
}
|
||||
} finally {
|
||||
picking = false;
|
||||
dialog.inert = false;
|
||||
if (!closed) { sync(); attachButton.focus(); }
|
||||
}
|
||||
});
|
||||
submitButton.addEventListener('click', () => void submit());
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
window.addEventListener('popstate', onBack, true);
|
||||
viewport?.addEventListener('resize', updateViewport);
|
||||
viewport?.addEventListener('scroll', updateViewport);
|
||||
window.addEventListener('resize', updateViewport);
|
||||
observer.observe(root, { childList: true });
|
||||
updateViewport();
|
||||
sync();
|
||||
requestAnimationFrame(() => {
|
||||
if (closed || !isActive()) return;
|
||||
const quote = overlay.querySelector('.channel-editor__context p');
|
||||
const expand = overlay.querySelector('.channel-editor__context-toggle');
|
||||
if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight;
|
||||
sync();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
});
|
||||
return { close };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Возвращает Promise<boolean>: true — подтвердили, false — отмена, Escape или тап мимо.
|
||||
export function confirmDialog({
|
||||
title = 'Подтвердите действие',
|
||||
text = '',
|
||||
confirmLabel = 'Да',
|
||||
cancelLabel = 'Отмена',
|
||||
danger = false,
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const opener = document.activeElement;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal confirm-dialog';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card confirm-dialog__card" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<h2 class="modal-title" id="confirm-dialog-title"></h2>
|
||||
<p class="confirm-dialog__text"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button type="button" class="secondary-btn" data-answer="no"></button>
|
||||
<button type="button" class="${danger ? 'destructive-btn confirm-dialog__danger' : 'primary-btn'}" data-answer="yes"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('.modal-title').textContent = title;
|
||||
const textEl = modal.querySelector('.confirm-dialog__text');
|
||||
textEl.textContent = text;
|
||||
textEl.hidden = !text;
|
||||
modal.querySelector('[data-answer="no"]').textContent = cancelLabel;
|
||||
modal.querySelector('[data-answer="yes"]').textContent = confirmLabel;
|
||||
|
||||
const finish = (answer) => {
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
resolve(answer);
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') { event.stopPropagation(); finish(false); }
|
||||
};
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) { finish(false); return; }
|
||||
const answer = event.target.closest('[data-answer]')?.dataset.answer;
|
||||
if (answer) finish(answer === 'yes');
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
document.body.append(modal);
|
||||
modal.querySelector('[data-answer="no"]').focus();
|
||||
});
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function createDropdownMenu({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
close({ focusAnchor: true });
|
||||
await item.action?.();
|
||||
});
|
||||
menuEl.append(button);
|
||||
@@ -154,6 +154,7 @@ export function createDropdownMenu({
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
menuEl.querySelector('button:not(:disabled)')?.focus();
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
@@ -172,8 +173,19 @@ export function createDropdownMenu({
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close({ focusAnchor: true });
|
||||
if (!portal) return;
|
||||
if (event.key === 'Escape' || event.key === 'Tab') {
|
||||
if (event.key === 'Escape') event.preventDefault();
|
||||
close({ focusAnchor: true });
|
||||
return;
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const buttons = [...menuEl.querySelectorAll('button:not(:disabled)')];
|
||||
if (!buttons.length) return;
|
||||
event.preventDefault();
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
export function attachMessageMenu(card, head, items) {
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'icon-btn channel-message-more';
|
||||
trigger.textContent = '⋯';
|
||||
trigger.setAttribute('aria-label', 'Действия сообщения');
|
||||
head.append(trigger);
|
||||
const menu = createDropdownMenu({ anchorEl: trigger, items });
|
||||
card.cleanup = () => menu.destroy();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Скрытый редактор палитры: открывается долгим нажатием на «День» или «Ночь» в настройках.
|
||||
import {
|
||||
PALETTE_PRESETS,
|
||||
PALETTE_ROLES,
|
||||
exportPalette,
|
||||
getPaletteSettings,
|
||||
importPalette,
|
||||
resolvePalette,
|
||||
resolveThemeMode,
|
||||
setPaletteSettings,
|
||||
} from '../services/theme-service.js';
|
||||
|
||||
export function openPaletteEditor({ theme = resolveThemeMode(), onClose } = {}) {
|
||||
let editTheme = theme === 'light' ? 'light' : 'dark';
|
||||
const opener = document.activeElement;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal palette-editor';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card palette-editor__card" role="dialog" aria-modal="true" aria-labelledby="palette-editor-title">
|
||||
<div class="palette-editor__head">
|
||||
<h2 class="modal-title" id="palette-editor-title">Цвета оформления</h2>
|
||||
<button class="icon-btn palette-editor__close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Основа</span>
|
||||
<div class="tabs tabs--auto palette-editor__presets" role="radiogroup" aria-label="Готовая палитра"></div>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Настраиваемая тема</span>
|
||||
<div class="tabs tabs--auto" role="radiogroup" aria-label="Тема для настройки">
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="light">День</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="dark">Ночь</button>
|
||||
</div>
|
||||
<p class="palette-editor__hint">Правки видны сразу. Меняется только выбранная тема.</p>
|
||||
</div>
|
||||
<div class="palette-editor__roles"></div>
|
||||
<details class="palette-editor__share">
|
||||
<summary>Поделиться палитрой</summary>
|
||||
<p class="palette-editor__hint">Скопируйте текст и отправьте команде или вставьте чужую палитру и нажмите «Применить».</p>
|
||||
<textarea class="input palette-editor__json" rows="8" spellcheck="false"></textarea>
|
||||
<p class="palette-editor__error" role="alert" hidden></p>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="copy">Скопировать</button>
|
||||
<button type="button" class="secondary-btn" data-action="import">Применить</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="reset-theme">Сбросить эту тему</button>
|
||||
<button type="button" class="primary-btn" data-action="done">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const card = modal.querySelector('.palette-editor__card');
|
||||
const presetsEl = modal.querySelector('.palette-editor__presets');
|
||||
const rolesEl = modal.querySelector('.palette-editor__roles');
|
||||
const jsonEl = modal.querySelector('.palette-editor__json');
|
||||
const errorEl = modal.querySelector('.palette-editor__error');
|
||||
|
||||
for (const [id, preset] of Object.entries(PALETTE_PRESETS)) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tab-btn';
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.dataset.preset = id;
|
||||
btn.textContent = preset.label;
|
||||
presetsEl.append(btn);
|
||||
}
|
||||
|
||||
const update = (mutate) => {
|
||||
const settings = getPaletteSettings();
|
||||
const next = { preset: settings.preset, custom: { dark: { ...settings.custom.dark }, light: { ...settings.custom.light } } };
|
||||
mutate(next);
|
||||
setPaletteSettings(next);
|
||||
render();
|
||||
};
|
||||
|
||||
function render() {
|
||||
const settings = getPaletteSettings();
|
||||
const colors = resolvePalette(editTheme, settings);
|
||||
const custom = settings.custom[editTheme];
|
||||
modal.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.preset === settings.preset));
|
||||
});
|
||||
modal.querySelectorAll('[data-edit-theme]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.editTheme === editTheme));
|
||||
});
|
||||
rolesEl.replaceChildren(...PALETTE_ROLES.map((role) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'palette-editor__role';
|
||||
const changed = Boolean(custom[role.id]);
|
||||
row.innerHTML = `
|
||||
<input type="color" value="${colors[role.id]}" aria-label="${role.label}">
|
||||
<span class="palette-editor__role-name">${role.label}${changed ? ' <span class="palette-editor__changed">изменён</span>' : ''}</span>
|
||||
<code class="palette-editor__role-value">${colors[role.id]}</code>
|
||||
`;
|
||||
const input = row.querySelector('input');
|
||||
input.addEventListener('input', () => {
|
||||
document.documentElement.style.setProperty(`--${role.id}`, input.value);
|
||||
row.querySelector('code').textContent = input.value;
|
||||
});
|
||||
input.addEventListener('change', () => update((next) => { next.custom[editTheme][role.id] = input.value; }));
|
||||
return row;
|
||||
}));
|
||||
jsonEl.value = exportPalette();
|
||||
errorEl.hidden = true;
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
onClose?.();
|
||||
};
|
||||
const onKeydown = (event) => { if (event.key === 'Escape') close(); };
|
||||
|
||||
modal.addEventListener('click', async (event) => {
|
||||
if (event.target === modal) { close(); return; }
|
||||
const presetBtn = event.target.closest('[data-preset]');
|
||||
if (presetBtn) { update((next) => { next.preset = presetBtn.dataset.preset; }); return; }
|
||||
// data-edit-theme, а не data-theme: data-theme стоит на <html>, и closest() находил бы его при любом клике.
|
||||
const themeBtn = event.target.closest('[data-edit-theme]');
|
||||
if (themeBtn) { editTheme = themeBtn.dataset.editTheme; render(); return; }
|
||||
if (event.target.closest('.palette-editor__close')) { close(); return; }
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'done') close();
|
||||
if (action === 'reset-theme') update((next) => { next.custom[editTheme] = {}; });
|
||||
if (action === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonEl.value);
|
||||
} catch {
|
||||
jsonEl.select();
|
||||
}
|
||||
}
|
||||
if (action === 'import') {
|
||||
try {
|
||||
importPalette(jsonEl.value);
|
||||
render();
|
||||
} catch {
|
||||
errorEl.textContent = 'Не удалось прочитать палитру: проверьте, что текст скопирован целиком.';
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
render();
|
||||
document.body.append(modal);
|
||||
card.querySelector('.palette-editor__close').focus();
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// data-profile-list / data-self-profile-action / data-profile-action обрабатывают profile-view.js и user-profile-view.js.
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
|
||||
export function escapeProfileHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const esc = escapeProfileHtml;
|
||||
|
||||
function statusChip({ kind, label, value, active }) {
|
||||
const n = Number(value || 0);
|
||||
return `
|
||||
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
|
||||
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
|
||||
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function statHtml({ kind, label, valueHtml, ariaLabel }) {
|
||||
return `
|
||||
<button type="button" class="pf-stat" data-profile-list="${esc(kind)}" aria-label="${esc(ariaLabel)}">
|
||||
<span class="pf-stat-value">${valueHtml}</span>
|
||||
<span class="pf-stat-label">${esc(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function contactRows(card) {
|
||||
return [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
]
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([label, value]) => `
|
||||
<div class="pf-row">
|
||||
<span class="pf-row-label">${esc(label)}</span>
|
||||
<span class="pf-row-value">${esc(value)}</span>
|
||||
</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.card карточка профиля (loadUserProfileCard)
|
||||
* @param {string} opts.login логин на случай, если в карточке его нет
|
||||
* @param {string} opts.tilesHtml плитки действий (свои/чужие)
|
||||
* @param {string} [opts.tilesWrapClass] доп. класс обёртки плиток (для меню «Добавить»)
|
||||
* @param {string} [opts.beforeTilesHtml] разметка внутри обёртки перед плитками
|
||||
*/
|
||||
export function profileCardHtml({ card, login = '', tilesHtml = '', tilesWrapClass = '', beforeTilesHtml = '', isSelf = true }) {
|
||||
const stats = card?.stats || {};
|
||||
const official = card?.accountRole === 'primary';
|
||||
const shining = card?.shineStatus === 'shining';
|
||||
const cardLogin = card?.login || login;
|
||||
const fullName = [card?.firstName, card?.lastName].map((v) => String(v || '').trim()).filter(Boolean).join(' ');
|
||||
const displayName = fullName || cardLogin || 'Профиль';
|
||||
const about = String(card?.about || '').trim();
|
||||
const spiritualPath = String(card?.spiritualPath || '').trim();
|
||||
const contacts = contactRows(card);
|
||||
const aboutRow = (about || isSelf) ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">О себе</span>
|
||||
<p class="pf-row-text${about ? '' : ' is-empty'}">${esc(about || 'Не заполнено')}</p>
|
||||
</div>` : '';
|
||||
const pathRow = spiritualPath ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">Духовный путь</span>
|
||||
<p class="pf-row-text">${esc(spiritualPath)}</p>
|
||||
</div>` : '';
|
||||
const listInner = aboutRow + contacts + pathRow;
|
||||
const listHtml = listInner.trim() ? `<div class="pf-list">${listInner}</div>` : '';
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const statusChips = [
|
||||
(official || Number(stats.primaryReceivedCount || 0) > 0) && statusChip({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, active: official }),
|
||||
(shining || Number(stats.shineReceivedCount || 0) > 0) && statusChip({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, active: shining }),
|
||||
].filter(Boolean);
|
||||
const statusesHtml = statusChips.length ? `<div class="pf-statuses">${statusChips.join('')}</div>` : '';
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
|
||||
return `
|
||||
<div class="pf-hero${shining ? ' is-shining' : ''}" aria-label="Профиль ${esc(cardLogin)}">
|
||||
<div class="pf-avatar user-profile-avatar-slot"></div>
|
||||
<h2 class="pf-name">${esc(displayName)}</h2>
|
||||
<div class="pf-login">@${esc(cardLogin)}</div>
|
||||
${statusesHtml}
|
||||
</div>
|
||||
|
||||
<div class="pf-stats">
|
||||
${statHtml({ kind: 'friends', label: 'Друзья', valueHtml: closeFriends ? `${friends}<small> · ${closeFriends} близк.</small>` : String(friends), ariaLabel: `Друзья: ${friends}, близкие: ${closeFriends}` })}
|
||||
${statHtml({ kind: 'channels_owned', label: 'Каналы', valueHtml: String(Number(stats.ownedPublicChannelsCount || 0)), ariaLabel: `Каналы: ${Number(stats.ownedPublicChannelsCount || 0)}` })}
|
||||
${statHtml({ kind: 'channels_following', label: 'Подписки', valueHtml: String(Number(stats.followingChannelsCount || 0)), ariaLabel: `Подписки: ${Number(stats.followingChannelsCount || 0)}` })}
|
||||
</div>
|
||||
|
||||
${tilesHtml ? `
|
||||
<div class="pf-tiles-wrap user-profile-actions-wrap ${esc(tilesWrapClass)}">
|
||||
${beforeTilesHtml}
|
||||
<div class="pf-tiles user-profile-actions">${tilesHtml}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${listHtml}`;
|
||||
}
|
||||
|
||||
export function profileTileHtml({ icon, label, attrs = '', iconMarkup = '' }) {
|
||||
return `<button type="button" class="pf-tile user-profile-action-btn" ${attrs}>${iconMarkup || iconHtml(icon)}<span>${esc(label)}</span></button>`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
@@ -6,7 +7,7 @@ function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
icon.innerHTML = iconHtml('down');
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,26 @@ import { state, authService } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
||||
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
||||
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю).
|
||||
const TOOLBAR_ICONS = {
|
||||
'messages-list': '<g transform="translate(12 12) scale(.88) translate(-11.5 -13)"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/><circle cx="8" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="11.7" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="15.4" cy="12" r="1.05" class="toolbar-svg-dot"/></g>',
|
||||
'channels-list': '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
'notifications-view': '<path d="M6.2 16.8V11a5.8 5.8 0 0 1 11.6 0v5.8l1.7 1.7h-15Z"/><path d="M10.2 21a2 2 0 0 0 3.6 0"/>',
|
||||
'profile-view': '<circle cx="12" cy="6.8" r="3.6"/><path d="M4.4 21v-2.1a5.6 5.6 0 0 1 5.6-5.6h4a5.6 5.6 0 0 1 5.6 5.6V21Z"/>',
|
||||
};
|
||||
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'messages-list', label: 'Личные' },
|
||||
{ pageId: 'channels-list', label: 'Каналы' },
|
||||
{ pageId: 'network-view', label: 'Связи', mandala: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления' },
|
||||
{ pageId: 'profile-view', label: 'Профиль' },
|
||||
];
|
||||
|
||||
function iconHtml(item) {
|
||||
return item.iconImg
|
||||
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />`
|
||||
: `<span>${item.icon}</span>`;
|
||||
function iconHtml(item, extra = '') {
|
||||
const glyph = item.mandala
|
||||
? `<img class="toolbar-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true" />`
|
||||
: `<svg class="toolbar-svg" viewBox="0 0 24 24" aria-hidden="true">${TOOLBAR_ICONS[item.pageId]}</svg>`;
|
||||
return `<span class="toolbar-icon${item.mandala ? ' toolbar-icon--mandala' : ''}">${glyph}${extra}</span>`;
|
||||
}
|
||||
|
||||
function normalizeCounters(payload = {}) {
|
||||
@@ -49,7 +54,7 @@ function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||
btn.append(badge);
|
||||
(btn.querySelector('.toolbar-icon') || btn).append(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||
@@ -126,25 +131,23 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const isMessages = item.pageId === 'messages-list';
|
||||
const isNetwork = item.pageId === 'network-view';
|
||||
const isNotifications = item.pageId === 'notifications-view';
|
||||
btn.type = 'button';
|
||||
if (item.pageId === active) btn.setAttribute('aria-current', 'page');
|
||||
btn.dataset.toolbarPage = item.pageId;
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||
if (isProfile) {
|
||||
btn.innerHTML = `
|
||||
${iconHtml(item)}
|
||||
<span class="toolbar-label-wrap">
|
||||
<span>${item.label}</span>
|
||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
${iconHtml(item, `<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
<span class="toolbar-connection-dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
</span>
|
||||
</span>`)}
|
||||
<span class="sr-only">${item.label}</span>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
}
|
||||
btn.title = item.label;
|
||||
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
@@ -15,6 +16,8 @@ function createActionButton(action = {}, cleanupFns) {
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else if (action.icon) {
|
||||
button.innerHTML = iconHtml(action.icon);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
@@ -56,7 +59,7 @@ export function createTopBar({
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.innerHTML = iconHtml('back');
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const paths = {
|
||||
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
|
||||
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
|
||||
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
|
||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
|
||||
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
|
||||
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||||
open: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
chevron: '<path d="m9 6 6 6-6 6"/>',
|
||||
back: '<path d="M19 12H5M11 5l-7 7 7 7"/>',
|
||||
down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
|
||||
edit: '<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
|
||||
wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v3"/><rect x="4" y="8" width="16" height="11" rx="2.5"/><circle cx="16" cy="13.5" r="1.2" fill="currentColor" stroke="none"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
|
||||
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
|
||||
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
|
||||
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
|
||||
};
|
||||
|
||||
export function iconHtml(name, filled = false) {
|
||||
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
|
||||
}
|
||||
@@ -177,11 +177,11 @@ function createPasswordModal() {
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать root key только сейчас</span>
|
||||
<span>Использовать главный ключ только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить root key на этом устройстве</span>
|
||||
<span>Сохранить главный ключ на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
|
||||
const passwordModal = createPasswordModal();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.className = 'settings-intro';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||
</p>
|
||||
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
|
||||
listBody.className = 'stack';
|
||||
const listStatus = document.createElement('p');
|
||||
listStatus.className = 'meta-muted';
|
||||
listStatus.textContent = 'Загрузка данных из PDA...';
|
||||
listStatus.textContent = 'Загрузка данных аккаунта...';
|
||||
listCard.append(listTitle, listHint, listBody, listStatus);
|
||||
|
||||
const addCard = document.createElement('div');
|
||||
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
|
||||
suggestEl.hidden = true;
|
||||
const addStatus = document.createElement('p');
|
||||
addStatus.className = 'meta-muted';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||
listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
|
||||
try {
|
||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const logins = uniqueLogins(parsed?.accessServers);
|
||||
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? 'Сервер доступа загружен из PDA.'
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
? 'Сервер доступа загружен из записи аккаунта.'
|
||||
: 'В записи аккаунта пока нет серверов доступа.';
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
|
||||
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
|
||||
target.innerHTML = `
|
||||
<span style="display:block; margin-bottom:0.55rem;">
|
||||
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
|
||||
Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
|
||||
</span>
|
||||
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
|
||||
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
|
||||
@@ -500,10 +494,10 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
||||
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
@@ -513,10 +507,10 @@ export function render({navigate, chrome}) {
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
|
||||
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
|
||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
@@ -613,15 +607,15 @@ export function render({navigate, chrome}) {
|
||||
title: 'Сменить сервер доступа?',
|
||||
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
|
||||
onConfirm: async () => {
|
||||
const nextList = [resolved.serverLogin];
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
|
||||
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
|
||||
const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
|
||||
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
const TRANSLIT = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
|
||||
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
|
||||
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||
};
|
||||
|
||||
function suggestChannelLogin(title) {
|
||||
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
|
||||
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-name">Технический логин канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
|
||||
<div class="meta-muted channel-link-preview" id="channel-link-preview"> </div>
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-title">Название канала</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-title-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-title">Как канал будет виден пользователям</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-name">Адрес канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
<div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
|
||||
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
|
||||
<div id="channel-name-error" class="inline-error"></div>
|
||||
</div>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
|
||||
<textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-description-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
|
||||
const renderChannelLinkPreview = (nameValue) => {
|
||||
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
|
||||
if (!ownerBlockchainName || !normalizedChannelName) {
|
||||
linkPreviewEl.innerHTML = ' ';
|
||||
linkPreviewEl.hidden = true;
|
||||
linkPreviewEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
linkPreviewEl.hidden = false;
|
||||
const directUrl = buildAbsoluteChannelUrl({
|
||||
ownerBlockchainName,
|
||||
channelName: normalizedChannelName,
|
||||
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
|
||||
titleErrorEl.textContent = titleCheck.error;
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
|
||||
|
||||
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
};
|
||||
|
||||
let nameEditedByHand = false;
|
||||
nameEl.addEventListener('input', () => {
|
||||
nameEditedByHand = nameEl.value.trim() !== '';
|
||||
const raw = String(nameEl.value || '');
|
||||
const sanitized = sanitizeChannelLoginInput(raw);
|
||||
if (raw !== sanitized) {
|
||||
nameEl.value = sanitized;
|
||||
window.alert(CHANNEL_LOGIN_HINT);
|
||||
}
|
||||
if (raw !== sanitized) nameEl.value = sanitized;
|
||||
updateValidation();
|
||||
if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
|
||||
});
|
||||
titleEl.addEventListener('input', () => {
|
||||
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
|
||||
updateValidation();
|
||||
});
|
||||
titleEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
avatarBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const check = updateValidation();
|
||||
if (check.name && /^[0-9]+$/.test(check.name)) {
|
||||
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
|
||||
nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
|
||||
nameEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' };
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
title: 'Новый публичный чат',
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
|
||||
}));
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'card stack advanced-settings-intro';
|
||||
intro.className = 'settings-intro';
|
||||
intro.innerHTML = `
|
||||
<p class="field-label">Скрытые возможности SHiNE</p>
|
||||
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
|
||||
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
|
||||
`;
|
||||
|
||||
const developerRow = createToggleRow({
|
||||
|
||||
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
controls.style.justifyContent = 'flex-start';
|
||||
controls.style.gap = '8px';
|
||||
controls.style.flexWrap = 'wrap';
|
||||
controls.className = 'action-pair';
|
||||
controls.innerHTML = `
|
||||
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
|
||||
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
|
||||
<button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="destructive-btn" type="button" data-action="clear">Очистить</button>
|
||||
`;
|
||||
|
||||
const status = document.createElement('div');
|
||||
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Лог пока пуст.';
|
||||
list.append(empty);
|
||||
status.className = 'status-line is-available';
|
||||
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
|
||||
status.textContent = `Записей: ${entries.length}`;
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
|
||||
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
|
||||
const entries = getAppLogEntries();
|
||||
if (!entries.length) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function renderTile(item, index) {
|
||||
if (item.pendingPlacement) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'ar-attachment-placement-flag';
|
||||
flag.textContent = 'Не добавлен в SHiNE';
|
||||
flag.textContent = 'Ещё не прикреплён к записи';
|
||||
meta.append(flag);
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
title: 'Файлы в блокчейне',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { navigateBack, getPreviousTrackedPath } from '../router.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
@@ -251,7 +252,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
@@ -410,12 +411,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
|
||||
});
|
||||
}
|
||||
|
||||
function subscribersWord(count) {
|
||||
const n = Math.abs(Number(count) || 0) % 100;
|
||||
const last = n % 10;
|
||||
if (n > 10 && n < 20) return 'подписчиков';
|
||||
if (last === 1) return 'подписчик';
|
||||
if (last >= 2 && last <= 4) return 'подписчика';
|
||||
return 'подписчиков';
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: '',
|
||||
title: 'О канале',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
@@ -471,61 +481,66 @@ export function render({ navigate, route, chrome }) {
|
||||
channelRootBlockHash: selector?.channelRootBlockHash,
|
||||
});
|
||||
const serverLink = buildChannelLink(shortRoute);
|
||||
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
|
||||
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
|
||||
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
|
||||
|
||||
const subscriptionTile = isOwnChannel ? '' : `
|
||||
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
|
||||
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
|
||||
</button>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="channel-about-hero">
|
||||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||||
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
|
||||
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
|
||||
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>О канале</h3>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</section>
|
||||
<div class="channel-about-tiles">
|
||||
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
|
||||
${subscriptionTile}
|
||||
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section channel-about-owner-section">
|
||||
<h3>Владелец канала</h3>
|
||||
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>@${escapeHtml(ownerLogin)}</span>
|
||||
</button>
|
||||
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>Ссылка на этом сервере</h3>
|
||||
<div class="channel-about-link-box">
|
||||
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
|
||||
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
|
||||
<div class="channel-about-list">
|
||||
<div class="channel-about-row channel-about-row--static">
|
||||
<span class="channel-about-row-label">Описание</span>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
|
||||
${isOwnChannel ? '' : `
|
||||
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
|
||||
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
|
||||
<button class="channel-about-row" id="channel-about-owner" type="button">
|
||||
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
|
||||
<span class="channel-about-row-main">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
|
||||
</button>
|
||||
`}
|
||||
<button class="channel-about-row" id="channel-about-copy-row" type="button">
|
||||
<span class="channel-about-row-main">
|
||||
<span class="channel-about-row-label">Ссылка</span>
|
||||
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (String(channel?.avaAr || '').trim()) {
|
||||
const avatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
avatar.style.setProperty('--channel-avatar-size', '112px');
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
|
||||
} else {
|
||||
content.querySelector('#channel-about-avatar-slot')?.remove();
|
||||
}
|
||||
const channelAvatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
|
||||
content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
|
||||
initials: ownerName.slice(0, 1).toUpperCase() || '?',
|
||||
size: 'md',
|
||||
className: 'channel-about-owner-avatar-img',
|
||||
alt: '',
|
||||
}));
|
||||
|
||||
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
|
||||
const profileRoute = makeProfileRoute(ownerLogin);
|
||||
@@ -537,7 +552,7 @@ export function render({ navigate, route, chrome }) {
|
||||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||
if (shortRoute) navigate(shortRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||
const copyLink = async () => {
|
||||
if (!serverLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(serverLink);
|
||||
@@ -545,7 +560,8 @@ export function render({ navigate, route, chrome }) {
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
|
||||
|
||||
const subscriptionButton = content.querySelector('#channel-about-subscription');
|
||||
subscriptionButton?.addEventListener('click', async () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
@@ -5,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
showToast,
|
||||
@@ -12,18 +17,17 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import { openChannelEditor } from '../components/channel-editor.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentCarouselElement,
|
||||
escapeHtml,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -63,7 +67,7 @@ function createThreadAvatar(login) {
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
@@ -78,7 +82,7 @@ function createThreadAvatar(login) {
|
||||
}
|
||||
: null,
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -198,6 +202,13 @@ function allFeedSummaries() {
|
||||
});
|
||||
}
|
||||
|
||||
const channelTitleByLabel = new Map();
|
||||
|
||||
function rememberChannelTitle(label, channel) {
|
||||
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
|
||||
if (label && title) channelTitleByLabel.set(label, title);
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
||||
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
||||
@@ -212,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
|
||||
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
||||
));
|
||||
if (!found) return '';
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
rememberChannelTitle(label, found.channel);
|
||||
return label;
|
||||
}
|
||||
|
||||
function resolveChannelHeadingFromNode(node) {
|
||||
@@ -297,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
|
||||
if (!row?.channel?.channelName) return '';
|
||||
|
||||
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
|
||||
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
rememberChannelTitle(label, row.channel);
|
||||
return label;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -444,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="thread-blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -490,120 +505,18 @@ function resolveNodeText(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDraftAttachments(container, attachments) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
});
|
||||
container.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'thread-reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#thread-reply-text');
|
||||
const attachmentsEl = root.querySelector('#thread-reply-attachments');
|
||||
const errorEl = root.querySelector('#thread-reply-error');
|
||||
const submitEl = root.querySelector('#thread-reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'thread-reply-modal',
|
||||
title: isRating ? 'Оценка' : 'Ответ',
|
||||
submitLabel: isRating ? 'Отправить' : 'Ответить',
|
||||
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
|
||||
context,
|
||||
key: `${draftKey}:${mode}`,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit(text),
|
||||
});
|
||||
|
||||
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
@@ -722,58 +635,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||
<div class="meta-muted inline-error" id="thread-edit-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
|
||||
</div>
|
||||
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textEl = root.querySelector('#thread-edit-text');
|
||||
const errorEl = root.querySelector('#thread-edit-error');
|
||||
if (textEl) textEl.value = String(initialText || '');
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
|
||||
return openChannelEditor({
|
||||
id: 'thread-edit-modal', title: 'Редактирование', submitLabel: 'Сохранить',
|
||||
placeholder: 'Текст сообщения', initialText, allowEmptyText,
|
||||
key: draftKey, allowAttachments: false, rawText: true, isActive,
|
||||
onSubmit: ({ text }) => onSave(text),
|
||||
});
|
||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
@@ -789,7 +657,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const shiningLikes = Number(node?.shiningLikesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const ratings = Number(node?.ratingsCount || 0);
|
||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||
const isOwnMessage = Boolean(state.session.isAuthorized && state.session.login) && String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase();
|
||||
const msgSubType = Number(node?.msgSubType || 0);
|
||||
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
|
||||
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
||||
@@ -809,6 +677,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
const menuItems = [];
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -829,7 +698,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
titleMain.append(loginEl, numberEl);
|
||||
title.append(titleMain);
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
@@ -846,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
|
||||
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
headRow.append(authorTile);
|
||||
@@ -928,21 +798,17 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.className = 'ui-button channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
const ok = window.confirm('Поставить лайк?');
|
||||
if (!ok) return;
|
||||
}
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
setActionTitle(likeButton, 'Лайк...');
|
||||
try {
|
||||
@@ -954,6 +820,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
targetBlockNumber: target?.blockNumber,
|
||||
});
|
||||
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
|
||||
} finally {
|
||||
if (likeButton.isConnected) likeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -961,18 +829,23 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
isActive: handlers.isActive,
|
||||
draftKey: `message:${refKey}`,
|
||||
context: {
|
||||
author,
|
||||
text: parsedText.text,
|
||||
attachmentLabel: parsedText.attachments[0]?.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
@@ -982,7 +855,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'ui-button channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
@@ -994,7 +867,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item';
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
|
||||
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
|
||||
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
|
||||
actions.append(likeButton, discussionButton, shareButton, replyButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
@@ -1006,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
setActionTitle(originalButton, 'Оригинал');
|
||||
originalButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
handlers.navigate(makeShineMessageRoute({
|
||||
@@ -1016,7 +893,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
messageBlockNumber: repostTarget.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalButton);
|
||||
menuItems.push({ label: 'Оригинал', action: () => originalButton.click() });
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
@@ -1038,7 +915,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
msgSubType,
|
||||
}), { isActive: handlers.isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -1052,14 +929,28 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
isActive: handlers.isActive,
|
||||
draftKey: `edit:${messageRefKey(target)}`,
|
||||
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
|
||||
allowEmptyText: parsedText.attachments.length > 0,
|
||||
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
|
||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
|
||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
card.append(actions);
|
||||
authorTile.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -1067,13 +958,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (!login) return;
|
||||
handlers.navigate(makeProfileRoute(login));
|
||||
});
|
||||
card.addEventListener('click', () => {
|
||||
handlers.onOpenThread(target);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -1083,11 +971,17 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
const nodeNumber = nextNumber();
|
||||
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
if (parent) {
|
||||
const context = document.createElement('p');
|
||||
context.className = 'thread-reply-context';
|
||||
const excerpt = parseMessageAttachments(resolveNodeText(parent)).text;
|
||||
context.textContent = `В ответ ${parent.authorLogin || 'автору'} · ${excerpt.slice(0, 100) || 'Вложение'}`;
|
||||
row.prepend(context);
|
||||
}
|
||||
wrap.append(row);
|
||||
|
||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1, branch.node));
|
||||
}
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||
@@ -1139,6 +1033,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const positionKey = `${state.session.login}:thread:${routeKey}`;
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
@@ -1148,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
const threadHeaderTitle = document.createElement('span');
|
||||
threadHeaderTitle.className = 'channel-header-title';
|
||||
threadHeaderTitle.textContent = 'Обсуждение';
|
||||
const threadHeaderChannel = document.createElement('span');
|
||||
threadHeaderChannel.className = 'channel-header-owner';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
chrome?.setTopbar(header);
|
||||
@@ -1172,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
@@ -1297,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
|
||||
onShare: async (target) => {
|
||||
try {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Тред',
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
title: 'Сияние · Обсуждение',
|
||||
text: 'Сообщение из обсуждения в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -1314,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onOpenThread: (target) => {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) {
|
||||
showStatus('Не удалось определить путь до треда.');
|
||||
showStatus('Не удалось определить ссылку на обсуждение.');
|
||||
return;
|
||||
}
|
||||
navigate(routePath);
|
||||
@@ -1348,6 +1240,8 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.append(statusBox);
|
||||
|
||||
const clearContent = () => {
|
||||
chrome?.setComposer(null);
|
||||
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
|
||||
for (const timerId of refreshTimers) window.clearTimeout(timerId);
|
||||
refreshTimers.clear();
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
@@ -1365,6 +1259,7 @@ export function render({ navigate, route, chrome }) {
|
||||
'#thread-reply-modal',
|
||||
'#thread-repost-modal',
|
||||
].join(','))) {
|
||||
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
@@ -1377,23 +1272,25 @@ export function render({ navigate, route, chrome }) {
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
const hadContent = !!screen.querySelector('.thread-block');
|
||||
const restorePosition = hadContent ? document.getElementById('app-screen')?.scrollTop : readChannelPosition(positionKey);
|
||||
if (!hadContent) clearContent();
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
|
||||
screen.append(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const skeleton = hadContent ? null : renderSkeleton(screen);
|
||||
|
||||
try {
|
||||
let resolvedMessage = selector.message;
|
||||
@@ -1468,7 +1365,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
const focus = payload?.focus || null;
|
||||
@@ -1499,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
|
||||
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
|
||||
threadHeaderButton.title = 'Открыть канал';
|
||||
threadHeaderButton.disabled = false;
|
||||
threadHeaderButton.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1510,6 +1408,7 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
}
|
||||
|
||||
clearContent();
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
localSeq += 1;
|
||||
@@ -1532,24 +1431,40 @@ export function render({ navigate, route, chrome }) {
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const focusTitle = document.createElement('h3');
|
||||
focusTitle.className = 'section-title';
|
||||
focusTitle.textContent = 'Текущее сообщение';
|
||||
focusWrap.append(focusTitle);
|
||||
focusTitle.textContent = 'Исходное сообщение';
|
||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||
const composer = document.createElement('div');
|
||||
composer.className = 'channel-composer';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'channel-compose-bar';
|
||||
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
reply.addEventListener('click', () => {
|
||||
const parsed = parseMessageAttachments(resolveNodeText(focus));
|
||||
openReplyModal({
|
||||
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
|
||||
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
|
||||
isActive: () => !disposed,
|
||||
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
|
||||
});
|
||||
});
|
||||
composer.append(reply);
|
||||
chrome?.setComposer(composer);
|
||||
}
|
||||
|
||||
const descendantsWrap = document.createElement('div');
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы и оценки';
|
||||
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов и оценок пока нет.';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
@@ -1565,7 +1480,8 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
if (!hasPendingScroll && focusWrap) {
|
||||
if (!hasPendingScroll && Number.isFinite(restorePosition)) restoreChannelPosition(restorePosition);
|
||||
if (!hasPendingScroll && !Number.isFinite(restorePosition) && focusWrap) {
|
||||
trackTimer(window.setTimeout(() => {
|
||||
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
|
||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
@@ -1573,10 +1489,17 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
retry.textContent = 'Повторить';
|
||||
retry.addEventListener('click', () => void refresh());
|
||||
failed.append(retry);
|
||||
screen.append(failed);
|
||||
}
|
||||
};
|
||||
@@ -1584,6 +1507,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
|
||||
+215
-316
@@ -1,3 +1,8 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { getPreviousTrackedPath } from '../router.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
@@ -10,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
dayKey,
|
||||
formatClockTime,
|
||||
formatDayLabel,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
@@ -17,6 +25,7 @@ import {
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { openChannelEditor } from '../components/channel-editor.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -35,7 +44,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -88,7 +97,7 @@ function createMessageAvatar(login) {
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
@@ -103,7 +112,7 @@ function createMessageAvatar(login) {
|
||||
}
|
||||
: null,
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -769,7 +778,7 @@ function bindSubmitOnPlainEnter(textarea, submit) {
|
||||
if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return;
|
||||
textarea.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
if (event.shiftKey || event.ctrlKey) return;
|
||||
if (!(event.ctrlKey || event.metaKey) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
submit();
|
||||
});
|
||||
@@ -824,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -870,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
button.addEventListener('click', async () => {
|
||||
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
@@ -880,101 +889,18 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#reply-text');
|
||||
const attachmentsEl = root.querySelector('#reply-attachments');
|
||||
const errorEl = root.querySelector('#reply-error');
|
||||
const submitEl = root.querySelector('#reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#reply-cancel')?.addEventListener('click', close);
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'reply-modal',
|
||||
title: isRating ? 'Оценка' : 'Ответ',
|
||||
submitLabel: isRating ? 'Отправить' : 'Ответить',
|
||||
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
|
||||
context,
|
||||
key: `${draftKey}:${mode}`,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit(text),
|
||||
});
|
||||
|
||||
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
|
||||
@@ -1142,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
if (list) {
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
|
||||
list.append(empty);
|
||||
} else {
|
||||
@@ -1246,104 +1172,31 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||
<p class="meta-muted">${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="draft-attachments" id="channel-message-attachments"></div>
|
||||
<div class="channel-message-tools">
|
||||
<select id="channel-message-type" class="input channel-message-type-select">
|
||||
<option value="${10}">Пост</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
</select>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const typeWrap = document.createElement('label');
|
||||
typeWrap.className = 'channel-editor__type';
|
||||
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'select channel-editor__type-select';
|
||||
typeSelect.title = 'Тип сообщения';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="10">Публикация</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
|
||||
`;
|
||||
typeWrap.append(typeSelect);
|
||||
|
||||
const textEl = root.querySelector('#channel-message-text');
|
||||
const typeEl = root.querySelector('#channel-message-type');
|
||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const submitEl = root.querySelector('#channel-message-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (typeEl) typeEl.disabled = inFlight;
|
||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const body = String(textEl?.value || '').trim();
|
||||
const msgSubType = Number(typeEl?.value || 10);
|
||||
if (!body && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
text: composeMessageWithAttachments(body, attachments),
|
||||
msgSubType,
|
||||
});
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'channel-message-modal',
|
||||
title: 'Новая запись',
|
||||
submitLabel: 'Опубликовать',
|
||||
placeholder: 'Напишите сообщение',
|
||||
key: `channel-post:${channelName}`,
|
||||
extraControl: typeWrap,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit({
|
||||
text,
|
||||
msgSubType: Number(typeSelect.value || 10),
|
||||
}),
|
||||
});
|
||||
|
||||
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||
@@ -1382,59 +1235,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||
<div class="meta-muted inline-error" id="edit-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
|
||||
</div>
|
||||
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#edit-message-text');
|
||||
const errorEl = root.querySelector('#edit-message-error');
|
||||
if (textEl) textEl.value = String(initialText || '');
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
|
||||
return openChannelEditor({
|
||||
id: 'edit-message-modal', title: 'Редактирование', submitLabel: 'Сохранить',
|
||||
placeholder: 'Текст сообщения', initialText, allowEmptyText,
|
||||
key: draftKey, allowAttachments: false, rawText: true, isActive,
|
||||
onSubmit: ({ text }) => onSave(text),
|
||||
});
|
||||
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function mapApiMessageToPost(message, selector, localNumber) {
|
||||
@@ -1488,7 +1295,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
|
||||
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
||||
isOwnMessage: Boolean(state.session.isAuthorized && state.session.login) && String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1810,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
function renderChannelMetaEventCard(event, dayLabel = '') {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? 'Создан канал'
|
||||
? 'Канал создан'
|
||||
: 'Изменено описание канала';
|
||||
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
|
||||
card.innerHTML = `
|
||||
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
|
||||
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
@@ -2034,6 +1842,7 @@ function renderPostCard(post, {
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const menuItems = [];
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -2054,8 +1863,9 @@ function renderPostCard(post, {
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
@@ -2104,6 +1914,15 @@ function renderPostCard(post, {
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isOwnerPost = !!selector
|
||||
&& !isDiarySelector(selector)
|
||||
&& !!post.messageRef?.blockchainName
|
||||
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
|
||||
if (isOwnerPost) {
|
||||
card.classList.add('channel-message-card--owner');
|
||||
authorTile.hidden = true;
|
||||
}
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const parsedBody = parseMessageAttachments(post.body);
|
||||
|
||||
@@ -2186,47 +2005,75 @@ function renderPostCard(post, {
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', (event) => {
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike });
|
||||
likeButton.disabled = true;
|
||||
try {
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
|
||||
} finally {
|
||||
if (likeButton.isConnected) likeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item channel-action-discussion';
|
||||
discussionButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Обсуждение</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||
discussionButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const route = buildThreadRoute(post.messageRef, selector);
|
||||
if (route) navigate(route);
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'ui-button channel-action-item channel-action-reply';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate,
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
isActive,
|
||||
draftKey: `message:${refKey}`,
|
||||
context: {
|
||||
author: post.authorLogin,
|
||||
text: parsedBody.text,
|
||||
attachmentLabel: parsedBody.attachments[0]?.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||
|
||||
actions.append(likeButton, replyButton);
|
||||
actions.append(likeButton, discussionButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'ui-button channel-action-item channel-action-share';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
@@ -2238,6 +2085,7 @@ function renderPostCard(post, {
|
||||
});
|
||||
|
||||
actions.append(shareButton);
|
||||
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
@@ -2251,15 +2099,13 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: post.targetRef.blockchainName,
|
||||
messageBlockNumber: post.targetRef.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalBtn);
|
||||
menuItems.push({ label: 'Оригинал', action: () => originalBtn.click() });
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
@@ -2281,7 +2127,7 @@ function renderPostCard(post, {
|
||||
msgSubType: post.msgSubType,
|
||||
}), { isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -2295,19 +2141,39 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
isActive,
|
||||
draftKey: `edit:${messageRefKey(post.messageRef)}`,
|
||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
|
||||
allowEmptyText: parsedBody.attachments.length > 0,
|
||||
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
|
||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await onEdit(post.messageRef, '', { isDelete: true }); }
|
||||
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
if (isOwnerPost) {
|
||||
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'channel-message-meta';
|
||||
const moreButton = headRow.querySelector('.channel-message-more');
|
||||
meta.append(timestamp);
|
||||
if (moreButton) meta.append(moreButton);
|
||||
actions.append(meta);
|
||||
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
|
||||
}
|
||||
card.append(actions);
|
||||
card.addEventListener('click', () => {
|
||||
const route = buildThreadRoute(post.messageRef, selector);
|
||||
if (route) navigate(route);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -2325,13 +2191,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = 'Подписаться на канал';
|
||||
actionButton.type = 'button';
|
||||
actionButton.className = 'primary-btn channel-main-action';
|
||||
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
|
||||
|
||||
const addMessageButton = document.createElement('button');
|
||||
addMessageButton.type = 'button';
|
||||
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
|
||||
addMessageButton.textContent = 'Добавить сообщение';
|
||||
addMessageButton.className = 'channel-compose-bar';
|
||||
addMessageButton.setAttribute('aria-label', 'Написать в канал');
|
||||
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
addMessageButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onAddMessage();
|
||||
@@ -2363,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
|
||||
if (feedItems.length) {
|
||||
let lastDayKey = '';
|
||||
feedItems.forEach((item) => {
|
||||
const itemDayKey = dayKey(item.timestampMs);
|
||||
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
||||
if (startsNewDay) lastDayKey = itemDayKey;
|
||||
if (startsNewDay && item.type !== 'meta') {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'channel-day-separator';
|
||||
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
||||
feed.append(separator);
|
||||
}
|
||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
@@ -2372,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
unreadLineInserted = true;
|
||||
}
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
@@ -2396,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = channelData.isDiary
|
||||
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
|
||||
: 'Ждем ваших начинаний';
|
||||
@@ -2416,16 +2294,22 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
if (channelData.isDiary) {
|
||||
screen.append(feed, backButton);
|
||||
} else if (channelData.isOwnChannel) {
|
||||
screen.append(feed, addMessageButton);
|
||||
screen.append(feed);
|
||||
const composer = document.createElement('div');
|
||||
composer.className = 'channel-composer';
|
||||
composer.append(addMessageButton);
|
||||
handlers.chrome?.setComposer(composer);
|
||||
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
||||
screen.append(feed, actionButton);
|
||||
screen.append(actionButton, feed);
|
||||
} else {
|
||||
screen.append(feed);
|
||||
}
|
||||
|
||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
|
||||
const restorePosition = handlers.restorePosition;
|
||||
const pendingScrollTimer = Number.isFinite(restorePosition) && !hasPendingScrollTarget ? 0 : applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
if (Number.isFinite(restorePosition) && !hasPendingScrollTarget) restoreChannelPosition(restorePosition);
|
||||
const unreadScrollTimer = !Number.isFinite(restorePosition) && !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
|
||||
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
||||
: 0;
|
||||
|
||||
@@ -2500,6 +2384,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel';
|
||||
const positionKey = `${state.session.login}:channel:${routeKey}`;
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
@@ -2526,11 +2411,12 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
|
||||
let activeChannelData = null;
|
||||
let activeOpenEntrypointHistory = null;
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = createTopBar({
|
||||
@@ -2538,7 +2424,6 @@ export function render({ navigate, route, chrome }) {
|
||||
back: { onClick: () => navigate('channels-list') },
|
||||
className: 'channel-view-topbar',
|
||||
actions: [
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
{
|
||||
label: '⋮',
|
||||
title: 'Действия канала',
|
||||
@@ -2557,27 +2442,14 @@ export function render({ navigate, route, chrome }) {
|
||||
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
||||
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
||||
const items = [];
|
||||
if (apiData?.isOwnChannel && !apiData?.isDiary) {
|
||||
items.push({
|
||||
label: 'Добавить сообщение',
|
||||
action: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({
|
||||
label: 'Оглавление',
|
||||
action: () => {
|
||||
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
|
||||
else showToast('В этом канале пока нет оглавления');
|
||||
},
|
||||
});
|
||||
if (!apiData?.isOwnChannel) {
|
||||
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
|
||||
}
|
||||
@@ -2595,6 +2467,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
}
|
||||
}
|
||||
const aboutIndex = items.findIndex((item) => item.label === 'О канале');
|
||||
if (aboutIndex > 0) items.unshift(...items.splice(aboutIndex, 2));
|
||||
return items;
|
||||
},
|
||||
},
|
||||
@@ -2627,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
@@ -2842,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Каналы',
|
||||
text: 'Тред из канала SHiNE',
|
||||
text: 'Обсуждение в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routeToShare),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -2918,6 +2789,8 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.append(statusBox);
|
||||
|
||||
const clearContent = () => {
|
||||
chrome?.setComposer(null);
|
||||
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
cleanupSeenTracking = null;
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
@@ -2942,17 +2815,29 @@ export function render({ navigate, route, chrome }) {
|
||||
'#reply-modal',
|
||||
'#repost-modal',
|
||||
].join(',');
|
||||
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
|
||||
if (modalRoot.querySelector(ownedSelector)) {
|
||||
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
const hadContent = !!screen.querySelector('.channel-feed');
|
||||
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
|
||||
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
|
||||
const previousPath = getPreviousTrackedPath() || '';
|
||||
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
|
||||
const restorePosition = hadContent
|
||||
? getChannelScrollRoot()?.scrollTop
|
||||
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
|
||||
if (!hadContent) clearContent();
|
||||
activeChannelData = null;
|
||||
activeOpenEntrypointHistory = null;
|
||||
activeSelector = null;
|
||||
showStatus('');
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
|
||||
channelHeaderButton.disabled = true;
|
||||
channelHeaderButton.onclick = null;
|
||||
if (channelMoreButton) channelMoreButton.disabled = true;
|
||||
@@ -2962,7 +2847,7 @@ export function render({ navigate, route, chrome }) {
|
||||
channelEntrypointButton.onclick = null;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const skeleton = hadContent ? null : renderSkeleton(screen);
|
||||
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
@@ -2981,8 +2866,17 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
});
|
||||
};
|
||||
activeOpenEntrypointHistory = openEntrypointHistory;
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = titleLabel;
|
||||
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
|
||||
channelHeaderButton.replaceChildren();
|
||||
const titleNode = document.createElement('span');
|
||||
titleNode.className = 'channel-header-title';
|
||||
titleNode.textContent = titleLabel;
|
||||
const ownerNode = document.createElement('span');
|
||||
ownerNode.className = 'channel-header-owner';
|
||||
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
|
||||
channelHeaderButton.append(titleNode, ownerNode);
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
@@ -3009,8 +2903,11 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
clearContent();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
chrome,
|
||||
restorePosition,
|
||||
showStatus,
|
||||
isActive: () => !disposed,
|
||||
onAddMessage: () => {
|
||||
@@ -3094,7 +2991,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить канал.')); return; }
|
||||
if (isChannelsDemoMode()) {
|
||||
renderDemoFallback(screen, navigate, error);
|
||||
return;
|
||||
@@ -3108,6 +3006,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
formatListTime,
|
||||
readChannelNotificationsState,
|
||||
showToast,
|
||||
softHaptic,
|
||||
@@ -18,8 +19,9 @@ import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
@@ -30,6 +32,7 @@ const DIARY_DISPLAY_NAME = 'Дневник';
|
||||
const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
const listQueries = new Map();
|
||||
|
||||
function channelMenuIcon(name) {
|
||||
const paths = {
|
||||
@@ -120,6 +123,7 @@ function createChannelAvatar(channel = {}) {
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'lg',
|
||||
className: 'avatar-plain',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
@@ -752,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
|
||||
isOwnChannel: isOwn,
|
||||
isSubscribed: !isOwn,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
@@ -847,16 +851,20 @@ function toListModel(groups) {
|
||||
];
|
||||
}
|
||||
|
||||
function renderEmptyState() {
|
||||
function renderEmptyState(listState, navigate) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||
if (!state.session.isAuthorized) {
|
||||
return wrap;
|
||||
}
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = 'У вас пока нет доступных каналов.';
|
||||
wrap.append(text);
|
||||
text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
|
||||
const action = document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.className = 'primary-btn';
|
||||
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
|
||||
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
|
||||
wrap.append(heading, text, action);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@@ -957,12 +965,13 @@ function renderChannelMain(channel) {
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
|
||||
titleLine.append(title, time);
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||
technical.textContent = `@${channel.ownerName || 'автор'}`;
|
||||
technical.hidden = !!channel.isOwnChannel;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -976,7 +985,8 @@ function renderChannelMain(channel) {
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
|
||||
if (!channel.messagePreview) preview.classList.add('is-empty');
|
||||
|
||||
previewLine.append(preview);
|
||||
|
||||
@@ -997,10 +1007,15 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
|
||||
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
|
||||
return true;
|
||||
}).filter((channel) => {
|
||||
const query = String(listState.query || '').trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
|
||||
.some((value) => String(value || '').toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
container.append(renderEmptyState());
|
||||
container.append(renderEmptyState(listState, navigate));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1010,10 +1025,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed });
|
||||
|
||||
filtered.forEach((channel) => {
|
||||
const row = document.createElement('article');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'channel-row';
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
|
||||
|
||||
const avatar = createChannelAvatar(channel);
|
||||
|
||||
@@ -1047,6 +1064,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||
if (listState.disposed) return;
|
||||
const seq = ++listState.loadSeq;
|
||||
if (!silent) renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
@@ -1072,6 +1091,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
|
||||
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||
// The server/API implementation is preserved so the feature can be restored later.
|
||||
@@ -1096,7 +1116,12 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
navigate,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
if (Number.isFinite(listState.restorePosition)) {
|
||||
restoreChannelPosition(listState.restorePosition);
|
||||
listState.restorePosition = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
if (silent) return;
|
||||
setChannelsFeed(null, {});
|
||||
contentEl.innerHTML = '';
|
||||
@@ -1118,52 +1143,81 @@ export function render({ navigate, route, chrome }) {
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
|
||||
const listState = {
|
||||
restorePosition: readChannelPosition(positionKey),
|
||||
disposed: false,
|
||||
loadSeq: 0,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
query: listQueries.get(positionKey) || '',
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
],
|
||||
});
|
||||
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
title: 'Каналы',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
label: '+',
|
||||
title: 'Создать канал',
|
||||
ariaLabel: 'Создать канал',
|
||||
className: 'channels-create-btn',
|
||||
onClick: () => navigate('add-channel-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channels-list-controls';
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'channels-inline-search';
|
||||
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.value = listState.query;
|
||||
searchInput.placeholder = 'В вашем списке';
|
||||
searchInput.setAttribute('aria-label', 'Найти канал или автора');
|
||||
const serverSearchButton = document.createElement('button');
|
||||
serverSearchButton.type = 'button';
|
||||
serverSearchButton.className = 'text-btn channels-server-search';
|
||||
serverSearchButton.textContent = 'По @автору';
|
||||
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||
searchWrap.append(searchInput, serverSearchButton);
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs tabs--three';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('aria-label', 'Фильтр каналов');
|
||||
tabs.addEventListener('keydown', (event) => {
|
||||
const buttons = [...tabs.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
});
|
||||
[
|
||||
[CHANNELS_VIEW_ALL, 'Все'],
|
||||
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
|
||||
[CHANNELS_VIEW_OWNED, 'Мои'],
|
||||
].forEach(([mode, label]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'tab-btn';
|
||||
button.textContent = label;
|
||||
button.setAttribute('role', 'tab');
|
||||
const selected = listState.viewMode === mode;
|
||||
button.classList.toggle('active', selected);
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
|
||||
tabs.append(button);
|
||||
});
|
||||
controls.append(searchWrap, tabs);
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
@@ -1192,13 +1246,16 @@ export function render({ navigate, route, chrome }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
|
||||
};
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
listState.restorePosition = undefined;
|
||||
listState.query = searchInput.value;
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl);
|
||||
screen.append(controls, contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
@@ -1210,9 +1267,12 @@ export function render({ navigate, route, chrome }) {
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
if (listState.disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
listQueries.set(positionKey, listState.query);
|
||||
listState.disposed = true;
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
unsubscribeCountersChanged();
|
||||
channelsFilterMenu.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
stopAllTwemojiAnimations,
|
||||
} from '../components/emoji-picker.js?v=202607152130';
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
|
||||
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
hideToolbar: true,
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
@@ -81,7 +82,7 @@ function chatRelationLabel(value) {
|
||||
case 'close_friend': return 'Близкий друг';
|
||||
case 'friend': return 'Друг';
|
||||
case 'contact': return 'Контакт';
|
||||
default: return 'Не в контактах';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,9 @@ function createChatHeaderParts(login, navigate) {
|
||||
const lastName = String(currentPeer?.lastName || '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
nameEl.textContent = fullName || cleanLogin;
|
||||
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||
const relation = chatRelationLabel(currentPeer?.relationType);
|
||||
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
|
||||
metaEl.hidden = !metaEl.textContent;
|
||||
const avatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName,
|
||||
@@ -1058,7 +1061,19 @@ function renderLog(
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||
let lastDayKey = '';
|
||||
messages.forEach((msg) => {
|
||||
const msgTimeMs = resolveMessageTimeMs(msg);
|
||||
const msgDayKey = dayKey(msgTimeMs);
|
||||
if (msgDayKey && msgDayKey !== lastDayKey) {
|
||||
lastDayKey = msgDayKey;
|
||||
const daySep = document.createElement('div');
|
||||
daySep.className = 'dm-day-separator';
|
||||
const dayLabel = document.createElement('span');
|
||||
dayLabel.textContent = formatDayLabel(msgTimeMs);
|
||||
daySep.append(dayLabel);
|
||||
list.append(daySep);
|
||||
}
|
||||
const isUnreadBoundary = showUnreadSeparator
|
||||
&& !unreadSeparatorInserted
|
||||
&& separatorMessageKey
|
||||
@@ -1176,7 +1191,8 @@ function renderLog(
|
||||
|
||||
const timeNode = document.createElement('span');
|
||||
timeNode.className = 'bubble-time';
|
||||
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
|
||||
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
metaNode.append(timeNode);
|
||||
|
||||
const status = resolveDeliveryStatus(messages, msg);
|
||||
@@ -1812,7 +1828,7 @@ export function render({ navigate, route, chrome }) {
|
||||
updateSendButtonMode();
|
||||
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
|
||||
showToast(
|
||||
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
{ kind: 'error', timeoutMs: 2600 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
|
||||
let searchSeq = 0;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input dm-input contact-search-input';
|
||||
input.className = 'search-field__input contact-search-input';
|
||||
input.type = 'text';
|
||||
input.name = 'contact';
|
||||
input.placeholder = 'Введите начало логина';
|
||||
input.placeholder = 'Логин или его начало';
|
||||
input.setAttribute('aria-label', 'Логин для поиска');
|
||||
input.enterKeyHint = 'search';
|
||||
input.autocomplete = 'off';
|
||||
input.maxLength = 80;
|
||||
|
||||
const resultsCard = document.createElement('section');
|
||||
resultsCard.className = 'card stack contact-search-results-card';
|
||||
resultsCard.className = 'stack contact-search-results';
|
||||
resultsCard.hidden = true;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchTimer = 0;
|
||||
void runSearch();
|
||||
}, 2000);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const searchButton = document.createElement('button');
|
||||
searchButton.className = 'primary-btn dm-send-btn';
|
||||
searchButton.className = 'search-field__action';
|
||||
searchButton.type = 'button';
|
||||
searchButton.textContent = 'Поиск';
|
||||
searchButton.textContent = 'Найти';
|
||||
searchButton.addEventListener('click', async () => {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer);
|
||||
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
|
||||
input.addEventListener('input', () => {
|
||||
scheduleSearch();
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
searchButton.click();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'contact-search-actions';
|
||||
controls.append(searchButton);
|
||||
|
||||
const formCard = document.createElement('section');
|
||||
formCard.className = 'card stack contact-search-form-card';
|
||||
formCard.append(input, controls);
|
||||
const formCard = document.createElement('label');
|
||||
formCard.className = 'search-field contact-search-field';
|
||||
formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
|
||||
formCard.append(input, searchButton);
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Поиск контактов',
|
||||
title: 'Найти человека',
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}));
|
||||
screen.append(
|
||||
|
||||
@@ -207,7 +207,7 @@ async function forceUiUpdateNow() {
|
||||
function showClientUpdateHelp() {
|
||||
window.alert(
|
||||
'Если UI не обновился:\n\n'
|
||||
+ '1) Закройте вкладки с SHiNE.\n'
|
||||
+ '1) Закройте вкладки с Сиянием.\n'
|
||||
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
|
||||
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
|
||||
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
|
||||
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.className = 'nav-list settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
<button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
|
||||
<button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
|
||||
<button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
|
||||
<button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
|
||||
<button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
|
||||
`;
|
||||
|
||||
const appLogBtn = card.querySelector('#settings-app-log');
|
||||
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
|
||||
const setPwaLabel = (text) => {
|
||||
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
|
||||
if (labelEl) labelEl.textContent = text;
|
||||
else pwaInstallBtn.textContent = text;
|
||||
};
|
||||
const syncPwaButtonLabel = () => {
|
||||
if (isStandalonePwaMode()) {
|
||||
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
|
||||
setPwaLabel('Приложение установлено — проверить уведомления');
|
||||
return;
|
||||
}
|
||||
if (canInstallPwa()) {
|
||||
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
|
||||
setPwaLabel('Установить как приложение');
|
||||
return;
|
||||
}
|
||||
pwaInstallBtn.textContent = 'Как установить PWA';
|
||||
setPwaLabel('Установка на главный экран');
|
||||
};
|
||||
|
||||
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
|
||||
|
||||
@@ -250,7 +250,7 @@ export function render({navigate, chrome}) {
|
||||
passwordDialog.style.inset = '0';
|
||||
passwordDialog.style.zIndex = '30';
|
||||
passwordDialog.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
|
||||
@@ -292,7 +292,7 @@ export function render({navigate, chrome}) {
|
||||
dialogOverlay.style.inset = '0';
|
||||
dialogOverlay.style.zIndex = '2';
|
||||
dialogOverlay.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim);"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
|
||||
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
@@ -11,24 +12,49 @@ import {
|
||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||
|
||||
function formatSessionType(sessionType) {
|
||||
if (Number(sessionType) === 100) return 'Homeserver';
|
||||
if (Number(sessionType) === 50) return 'Wallet';
|
||||
if (Number(sessionType) === 1) return 'Client';
|
||||
return `Type ${Number(sessionType) || 0}`;
|
||||
if (Number(sessionType) === 100) return 'Домашний сервер';
|
||||
if (Number(sessionType) === 50) return 'Кошелёк';
|
||||
if (Number(sessionType) === 1) return 'Приложение';
|
||||
return `Тип ${Number(sessionType) || 0}`;
|
||||
}
|
||||
|
||||
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
|
||||
function describeClient(userAgent) {
|
||||
const ua = String(userAgent || '');
|
||||
if (!ua) return 'Неизвестное устройство';
|
||||
const browser = /Edg\//.test(ua) ? 'Edge'
|
||||
: /OPR\/|Opera/.test(ua) ? 'Opera'
|
||||
: /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: '';
|
||||
const os = /iPhone/.test(ua) ? 'iPhone'
|
||||
: /iPad/.test(ua) ? 'iPad'
|
||||
: /Android/.test(ua) ? 'Android'
|
||||
: /Windows/.test(ua) ? 'Windows'
|
||||
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
|
||||
: /Linux|X11/.test(ua) ? 'Linux'
|
||||
: '';
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
|
||||
}
|
||||
|
||||
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = new Date(ms);
|
||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
|
||||
if (days === 0) return `сегодня, ${time}`;
|
||||
if (days === 1) return `вчера, ${time}`;
|
||||
const sameYear = date.getFullYear() === new Date().getFullYear();
|
||||
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
|
||||
return `${day}, ${time}`;
|
||||
}
|
||||
|
||||
function formatOnlineStatus(onlineOnThisServer) {
|
||||
return onlineOnThisServer ? 'Online now' : 'Offline';
|
||||
return onlineOnThisServer ? 'в сети' : 'не в сети';
|
||||
}
|
||||
|
||||
function sortSessionsByOnline(sessions = []) {
|
||||
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Устройства',
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.className = 'nav-list';
|
||||
actions.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
|
||||
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
|
||||
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
const sessionsBlock = document.createElement('div');
|
||||
sessionsBlock.className = 'card stack';
|
||||
sessionsBlock.className = 'stack device-sessions';
|
||||
|
||||
const buildList = () => {
|
||||
sessionsBlock.innerHTML = '';
|
||||
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
|
||||
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
||||
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
||||
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
|
||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||
</div>
|
||||
<span class="session-item__main">
|
||||
<strong class="session-item__title"></strong>
|
||||
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
|
||||
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
|
||||
</span>
|
||||
<span class="session-item__side">
|
||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
</div>
|
||||
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||
</span>
|
||||
`;
|
||||
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
|
||||
item.title = String(session.clientInfoFromClient || '');
|
||||
const geoEl = item.querySelector('.session-item__geo');
|
||||
if (geoEl) geoEl.textContent = session.geo;
|
||||
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
||||
return item;
|
||||
};
|
||||
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMenu = document.createElement('div');
|
||||
currentMenu.className = 'stack';
|
||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('Хотите завершить текущую сессию?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
closeServerSession: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
}
|
||||
});
|
||||
currentMenu.append(endCurrentSessionBtn);
|
||||
|
||||
const othersMenu = document.createElement('div');
|
||||
othersMenu.className = 'stack';
|
||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
|
||||
const groupTitle = (text) => {
|
||||
const title = document.createElement('p');
|
||||
title.className = 'nav-list__title';
|
||||
title.textContent = text;
|
||||
return title;
|
||||
};
|
||||
const currentList = document.createElement('div');
|
||||
currentList.className = 'nav-list';
|
||||
currentList.append(createSessionItem(current, true));
|
||||
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
|
||||
|
||||
if (others.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Других сеансов нет.';
|
||||
othersMenu.append(empty);
|
||||
sessionsBlock.append(empty);
|
||||
} else {
|
||||
others.forEach((session) => {
|
||||
othersMenu.append(createSessionItem(session, false));
|
||||
});
|
||||
const othersList = document.createElement('div');
|
||||
othersList.className = 'nav-list';
|
||||
others.forEach((session) => othersList.append(createSessionItem(session, false)));
|
||||
sessionsBlock.append(othersList);
|
||||
}
|
||||
|
||||
sessionsBlock.append(currentMenu, othersMenu);
|
||||
};
|
||||
|
||||
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||
async function reloadSessions() {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список сессий обновлён.');
|
||||
setAuthInfo('Список сеансов обновлён.');
|
||||
showToast('Список сеансов обновлён');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
showToast(error.message, { kind: 'error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildList();
|
||||
screen.append(actions, sessionsBlock);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function render() {
|
||||
amountHint.style.fontSize = '22px';
|
||||
amountHint.style.fontWeight = '800';
|
||||
amountHint.style.lineHeight = '1.25';
|
||||
amountHint.style.color = '#1fc97a';
|
||||
amountHint.style.color = 'var(--success)';
|
||||
amountHint.style.textAlign = 'center';
|
||||
amountHint.textContent = '';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function render() {
|
||||
balanceHint.style.margin = '0';
|
||||
balanceHint.style.textAlign = 'center';
|
||||
balanceHint.style.fontSize = '14px';
|
||||
balanceHint.style.color = 'rgba(255,255,255,0.9)';
|
||||
balanceHint.style.color = 'var(--text-primary)';
|
||||
balanceHint.textContent = '';
|
||||
|
||||
const closeHint = document.createElement('p');
|
||||
@@ -79,7 +79,7 @@ export function render() {
|
||||
closeHint.style.fontSize = '26px';
|
||||
closeHint.style.fontWeight = '800';
|
||||
closeHint.style.lineHeight = '1.25';
|
||||
closeHint.style.color = '#1fc97a';
|
||||
closeHint.style.color = 'var(--success)';
|
||||
closeHint.style.textAlign = 'center';
|
||||
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
|
||||
|
||||
@@ -140,7 +140,7 @@ export function render() {
|
||||
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
|
||||
status.style.fontSize = '13px';
|
||||
status.style.fontWeight = '500';
|
||||
status.style.color = 'rgba(255,255,255,0.88)';
|
||||
status.style.color = 'var(--text-secondary)';
|
||||
fillBtn.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
amountHint.style.display = '';
|
||||
|
||||
@@ -11,7 +11,7 @@ export const pageMeta = { id: 'entry-settings-view', title: 'Настройки
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
|
||||
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ export function render({ navigate }) {
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
|
||||
checkButton.className = 'secondary-btn server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
@@ -132,7 +132,7 @@ export function render({ navigate }) {
|
||||
|
||||
if (isLocalDemoAvailable()) {
|
||||
const localDemoButton = document.createElement('button');
|
||||
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
|
||||
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
|
||||
localDemoButton.type = 'button';
|
||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||
localDemoButton.addEventListener('click', () => {
|
||||
@@ -143,7 +143,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const serverUiButton = document.createElement('button');
|
||||
serverUiButton.className = 'shine-btn shine-btn--settings';
|
||||
serverUiButton.className = 'secondary-btn';
|
||||
serverUiButton.type = 'button';
|
||||
serverUiButton.textContent = 'Настроить свой сервер';
|
||||
serverUiButton.addEventListener('click', () => {
|
||||
@@ -152,13 +152,13 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'shine-btn shine-btn--settings';
|
||||
cancelButton.className = 'secondary-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'shine-btn shine-btn--primary';
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', async () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function render({ navigate }) {
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
|
||||
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
|
||||
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
|
||||
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
|
||||
@@ -14,55 +14,38 @@ export function render({navigate, chrome}) {
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
const topbar = createTopBar({
|
||||
title: 'Язык / Language',
|
||||
back: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}));
|
||||
});
|
||||
const embedTopbar = !state.session.isAuthorized;
|
||||
if (!embedTopbar) chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack language-choice-card';
|
||||
card.innerHTML = `
|
||||
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
|
||||
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
|
||||
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
|
||||
<span class="language-segmented-thumb" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="language-choice-actions">
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
|
||||
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const syncSelection = () => {
|
||||
card.dataset.language = pendingLanguage;
|
||||
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||
const selected = item.dataset.language === pendingLanguage;
|
||||
item.classList.toggle('is-selected', selected);
|
||||
item.setAttribute('aria-checked', String(selected));
|
||||
card.className = 'nav-list language-list';
|
||||
card.setAttribute('role', 'radiogroup');
|
||||
card.setAttribute('aria-label', 'Язык интерфейса / Interface language');
|
||||
[['ru', 'Русский'], ['en', 'English']].forEach(([code, label]) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'nav-row language-row';
|
||||
row.setAttribute('role', 'radio');
|
||||
row.setAttribute('aria-checked', String(pendingLanguage === code));
|
||||
row.innerHTML = `<span class="nav-row__label"></span>`;
|
||||
row.querySelector('.nav-row__label').textContent = label;
|
||||
row.addEventListener('click', () => {
|
||||
pendingLanguage = code;
|
||||
saveEntryLanguage(code);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
};
|
||||
syncSelection();
|
||||
|
||||
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
|
||||
syncSelection();
|
||||
});
|
||||
});
|
||||
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
|
||||
saveEntryLanguage(pendingLanguage);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
card.append(row);
|
||||
});
|
||||
|
||||
if (embedTopbar) screen.append(topbar);
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function render({ navigate }) {
|
||||
const remoteText = document.createElement('p');
|
||||
remoteText.className = 'auth-copy';
|
||||
const serverLoginEl = document.createElement('strong');
|
||||
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
|
||||
const textBefore = document.createTextNode('Этот пользователь Сияния зарегистрирован на другом сервере доступа: ');
|
||||
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
|
||||
|
||||
const serverLinkEl = document.createElement('a');
|
||||
@@ -111,11 +111,11 @@ export function render({ navigate }) {
|
||||
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
|
||||
|
||||
if (resolution === 'NOT_FOUND') {
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в Сиянии.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'NO_ACCESS_SERVER') {
|
||||
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
|
||||
setStatus(status, 'Пользователь зарегистрирован в Сиянии, но для него не найден действующий сервер доступа.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'REMOTE') {
|
||||
@@ -155,7 +155,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
createTopBar({
|
||||
title: '',
|
||||
title: 'Вход',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: async () => {
|
||||
|
||||
@@ -8,21 +8,13 @@ import {
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
import { formatListTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
const SVG_CHEVRON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
return `${normalized.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
|
||||
|
||||
async function resolveDialogPreview(dialog) {
|
||||
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
|
||||
if (localText) return localText;
|
||||
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
|
||||
String(state.session.storagePwdInMemory || '').trim(),
|
||||
].join('|');
|
||||
|
||||
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
|
||||
// чтобы превью появилось, когда пароль снова будет введён.
|
||||
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
|
||||
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
|
||||
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
|
||||
|
||||
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = 'Сообщение недоступно';
|
||||
const fallback = DM_PREVIEW_ENCRYPTED;
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
return formatRelativeTime(ts);
|
||||
return formatListTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -201,63 +198,45 @@ export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'dm-head-brand';
|
||||
const logoWrap = document.createElement('span');
|
||||
logoWrap.className = 'dm-head-logo-wrap';
|
||||
logoWrap.setAttribute('aria-hidden', 'true');
|
||||
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
|
||||
brand.append(logoWrap);
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = document.createElement('button');
|
||||
filterTitle.type = 'button';
|
||||
filterTitle.className = 'dm-head-filter-title';
|
||||
filterTitle.textContent = 'Чаты';
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
friend: 'Друзья',
|
||||
contact: 'Контакты',
|
||||
none: 'Новые',
|
||||
};
|
||||
const CHAT_FILTERS = [
|
||||
['all', 'Все'],
|
||||
['close_friend', 'Близкие'],
|
||||
['friend', 'Друзья'],
|
||||
['contact', 'Контакты'],
|
||||
['none', 'Новые'],
|
||||
];
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: filterTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
|
||||
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||
],
|
||||
const filterRow = document.createElement('div');
|
||||
filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
|
||||
filterRow.setAttribute('role', 'toolbar');
|
||||
filterRow.setAttribute('aria-label', 'Фильтр чатов');
|
||||
CHAT_FILTERS.forEach(([value, label]) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'chip';
|
||||
chip.dataset.filter = value;
|
||||
chip.textContent = label;
|
||||
chip.setAttribute('aria-pressed', String(value === currentChatFilter));
|
||||
chip.addEventListener('click', () => {
|
||||
if (currentChatFilter === value) return;
|
||||
currentChatFilter = value;
|
||||
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
|
||||
reloadForFilter();
|
||||
});
|
||||
filterRow.append(chip);
|
||||
});
|
||||
|
||||
const searchIconHtml = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
`;
|
||||
const head = createTopBar({
|
||||
left: brand,
|
||||
center: filterTitle,
|
||||
title: 'Чаты',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
|
||||
],
|
||||
},
|
||||
icon: 'search',
|
||||
title: 'Найти человека',
|
||||
ariaLabel: 'Найти человека',
|
||||
className: 'messages-search-btn',
|
||||
onClick: () => navigate('contact-search-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -269,9 +248,7 @@ function renderRow(item) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const relationFlag = normalizeRelationFlag(item.relationFlag);
|
||||
const relationBadge = relationFlag === 'none'
|
||||
? 'не в контактах'
|
||||
: relationLabel(relationFlag);
|
||||
const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
|
||||
const avatarEl = createDmAvatar(item.peerLogin, {
|
||||
avatar: item.avatar,
|
||||
firstName: item.firstName,
|
||||
@@ -280,19 +257,19 @@ function renderRow(item) {
|
||||
avatarEl.classList.add('avatar');
|
||||
const avatarWrap = document.createElement('div');
|
||||
avatarWrap.className = 'dm-av dm-av--default';
|
||||
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
|
||||
avatarWrap.append(avatarEl);
|
||||
row.innerHTML = `
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-titleline dm-row-titlewrap">
|
||||
<strong class="dm-row-title"></strong>
|
||||
<span class="dm-contact-note">${relationBadge}</span>
|
||||
${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
|
||||
</div>
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<div class="dm-row-meta-col">
|
||||
<div class="dm-row-meta-line">
|
||||
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||
</div>
|
||||
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||
</div>
|
||||
@@ -310,6 +287,7 @@ function renderRow(item) {
|
||||
void resolveDialogPreview(item).then((text) => {
|
||||
if (!previewEl?.isConnected) return;
|
||||
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
|
||||
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
|
||||
});
|
||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
|
||||
return row;
|
||||
@@ -387,20 +365,13 @@ function renderRow(item) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет диалогов';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
let dividerInserted = false;
|
||||
rows.forEach((item) => {
|
||||
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
list.append(divider);
|
||||
dividerInserted = true;
|
||||
}
|
||||
list.append(renderRow(item));
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -444,12 +415,10 @@ function renderRow(item) {
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
screen.append(filterRow, list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
chatFilterMenu.destroy();
|
||||
};
|
||||
screen.cleanup = () => {};
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
@@ -358,7 +357,7 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) {
|
||||
.filter(Boolean);
|
||||
if (failedParents.length) {
|
||||
const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', ');
|
||||
throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
|
||||
throw new Error(`Друзья друзей: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
|
||||
}
|
||||
|
||||
// ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра.
|
||||
@@ -930,16 +929,16 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
|
||||
function updateHistoryChip() {
|
||||
if (!(historyChip instanceof HTMLButtonElement)) return;
|
||||
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
|
||||
historyChip.textContent = historyDepth > 0 ? `Недавние · ${historyDepth}` : 'Недавние';
|
||||
historyChip.classList.toggle('is-active', historyDepth > 0);
|
||||
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
|
||||
historyChip.disabled = x2Enabled;
|
||||
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
|
||||
historyChip.title = x2Enabled
|
||||
? 'X2 показывает отдельную карту и временно не использует историю.'
|
||||
? '«Друзья друзей» показывают отдельную карту — недавние временно не используются.'
|
||||
: (historyDepth > 0
|
||||
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
|
||||
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.');
|
||||
? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
|
||||
: 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
|
||||
}
|
||||
|
||||
function cycleHistoryDepth() {
|
||||
@@ -956,7 +955,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
if (!(x2Chip instanceof HTMLButtonElement)) return;
|
||||
x2Chip.classList.toggle('is-active', x2Enabled);
|
||||
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
|
||||
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
|
||||
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы скрыть.' : 'Показать друзей друзей.';
|
||||
updateHistoryChip();
|
||||
}
|
||||
|
||||
@@ -1289,26 +1288,16 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
|
||||
|
||||
const searchIconHtml = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
`;
|
||||
const header = createTopBar({
|
||||
title: 'Связи',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню связей',
|
||||
ariaLabel: 'Открыть меню связей',
|
||||
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||
],
|
||||
},
|
||||
icon: 'search',
|
||||
title: 'Найти пользователя',
|
||||
ariaLabel: 'Найти пользователя',
|
||||
className: 'network-search-btn',
|
||||
onClick: openSearchModal,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1365,7 +1354,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
x2Chip = document.createElement('button');
|
||||
x2Chip.type = 'button';
|
||||
x2Chip.className = 'fg-filter-chip fg-x2-chip';
|
||||
x2Chip.textContent = 'X2';
|
||||
x2Chip.textContent = 'Друзья друзей';
|
||||
x2Chip.addEventListener('click', () => { void toggleX2(); });
|
||||
filterBar.append(x2Chip);
|
||||
updateX2Chip();
|
||||
|
||||
@@ -106,8 +106,27 @@ function easeOutCubic(t) {
|
||||
return 1 - x * x * x;
|
||||
}
|
||||
|
||||
const RELATION_COLORS_LIGHT = {
|
||||
family: 'rgba(184, 92, 20, 0.9)',
|
||||
friend: 'rgba(38, 96, 196, 0.9)',
|
||||
close_friend: 'rgba(38, 96, 196, 0.98)',
|
||||
business: 'rgba(108, 62, 200, 0.9)',
|
||||
contact: 'rgba(92, 98, 121, 0.75)',
|
||||
};
|
||||
|
||||
function isLightTheme() {
|
||||
return document.documentElement.dataset.theme === 'light';
|
||||
}
|
||||
|
||||
function shineStroke() {
|
||||
return isLightTheme()
|
||||
? { halo: '#0a7fb3', core: '#06567a', blend: 'normal' }
|
||||
: { halo: '#00e5ff', core: '#dffaff', blend: 'screen' };
|
||||
}
|
||||
|
||||
function relationColor(relationType) {
|
||||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||||
const palette = isLightTheme() ? RELATION_COLORS_LIGHT : RELATION_COLORS;
|
||||
return palette[relationType] || palette.contact;
|
||||
}
|
||||
|
||||
function resolveAvatarPhotoSrc(src) {
|
||||
@@ -1057,8 +1076,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
||||
if (pe > 0.02) {
|
||||
if (shine) {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
|
||||
const ss = shineStroke();
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
|
||||
} else {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
|
||||
}
|
||||
@@ -1067,8 +1087,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
|
||||
if (pe > 0.02) {
|
||||
if (shine) {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
|
||||
const ss = shineStroke();
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
|
||||
} else {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ function renderEmpty(activeTab) {
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или обсуждении, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
@@ -322,12 +322,15 @@ export function render({ navigate, chrome } = {}) {
|
||||
screen.className = 'stack notifications-screen';
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.className = 'tabs tabs--three notification-feed-tabs';
|
||||
tabs.setAttribute('role', 'radiogroup');
|
||||
tabs.setAttribute('aria-label', 'Категория уведомлений');
|
||||
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||
chrome?.setTopbar(createTopBar({
|
||||
center: tabs,
|
||||
className: 'notifications-topbar',
|
||||
title: 'Уведомления',
|
||||
className: 'topbar--root notifications-topbar',
|
||||
}));
|
||||
screen.append(tabs);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
@@ -352,15 +355,22 @@ export function render({ navigate, chrome } = {}) {
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; (btn.querySelector('.toolbar-icon') || btn).append(badge); }
|
||||
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
||||
}
|
||||
|
||||
function renderTabs(payload) {
|
||||
const counts = countsFromPayload(payload);
|
||||
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
||||
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||
const b=document.createElement('button'); b.type='button'; b.className='tab-btn notification-tab-btn'; b.dataset.tab=key; b.setAttribute('role','radio'); b.setAttribute('aria-checked',state.notificationsTab===key?'true':'false');
|
||||
b.textContent = label;
|
||||
if (counts[key] > 0) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tab-count';
|
||||
count.textContent = counts[key] > 99 ? '99+' : String(counts[key]);
|
||||
count.setAttribute('aria-label', `новых: ${counts[key]}`);
|
||||
b.append(count);
|
||||
}
|
||||
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||
return b;
|
||||
}));
|
||||
|
||||
@@ -114,7 +114,7 @@ export function render({ navigate, chrome }) {
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'stack profile-edit-card';
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row';
|
||||
@@ -127,14 +127,14 @@ export function render({ navigate, chrome }) {
|
||||
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
|
||||
<button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
|
||||
`;
|
||||
|
||||
const badgesRow = document.createElement('div');
|
||||
badgesRow.className = 'row';
|
||||
badgesRow.className = 'chip-row profile-edit-statuses';
|
||||
badgesRow.innerHTML = `
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
|
||||
<button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
|
||||
<button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
|
||||
`;
|
||||
|
||||
const status = document.createElement('div');
|
||||
@@ -142,16 +142,13 @@ export function render({ navigate, chrome }) {
|
||||
status.textContent = 'Загрузка параметров...';
|
||||
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'stack profile-param-list';
|
||||
listWrap.className = 'nav-list profile-param-list';
|
||||
|
||||
const relativesCard = document.createElement('div');
|
||||
relativesCard.className = 'card stack';
|
||||
relativesCard.innerHTML = `
|
||||
<div class="profile-param-value"><b>Друзья</b></div>
|
||||
<div class="meta-muted">
|
||||
Добавьте пользователя в друзья или в близкие друзья.
|
||||
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
|
||||
</div>
|
||||
<div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
|
||||
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
|
||||
`;
|
||||
|
||||
@@ -250,7 +247,7 @@ export function render({ navigate, chrome }) {
|
||||
|
||||
function updateStatusesUi() {
|
||||
if (accountRoleBtn) {
|
||||
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
|
||||
accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
|
||||
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
|
||||
}
|
||||
@@ -582,22 +579,22 @@ export function render({ navigate, chrome }) {
|
||||
function renderFields(fields) {
|
||||
listWrap.innerHTML = '';
|
||||
fields.forEach((field) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'nav-row profile-param-item';
|
||||
row.dataset.editField = field.key;
|
||||
row.style.cursor = 'pointer';
|
||||
const value = String(field.value || '').trim() || 'не заполнено';
|
||||
const isNameField = field.key === 'first_name' || field.key === 'last_name';
|
||||
const valueClass = isNameField ? 'profile-param-value profile-param-value-small' : 'profile-param-value';
|
||||
row.innerHTML = `<div class="${valueClass}"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
|
||||
const value = String(field.value || '').trim();
|
||||
row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
|
||||
listWrap.append(row);
|
||||
|
||||
if (field.key === 'last_name') {
|
||||
const genderRow = document.createElement('div');
|
||||
genderRow.className = 'card profile-param-item row';
|
||||
const genderRow = document.createElement('button');
|
||||
genderRow.type = 'button';
|
||||
genderRow.className = 'nav-row profile-param-item';
|
||||
genderRow.dataset.editGender = 'true';
|
||||
genderRow.style.cursor = 'pointer';
|
||||
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: <span data-gender-value>${escapeHtml(genderLabel(currentGender))}</span></div>`;
|
||||
const genderText = genderLabel(currentGender);
|
||||
const genderEmpty = genderText === 'Не указан';
|
||||
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
|
||||
listWrap.append(genderRow);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,104 +1,20 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
const numericValue = Number(value || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||
data-profile-list="${escapeHtml(kind)}"
|
||||
aria-label="${escapeHtml(label)}: ${numericValue}"
|
||||
>
|
||||
<span class="user-profile-metric-circle">${numericValue}</span>
|
||||
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function friendsMetricHtml(stats = {}) {
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric is-social-metric is-friends-combined"
|
||||
data-profile-list="friends"
|
||||
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
|
||||
>
|
||||
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
|
||||
<span class="user-profile-metric-label">Друзья</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function hasContacts(card) {
|
||||
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
|
||||
}
|
||||
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
if (!rows.length) {
|
||||
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||
}
|
||||
return rows.map(([label, value]) => `
|
||||
<div class="user-profile-contact-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<b>${escapeHtml(value)}</b>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || profile.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: login || 'Профиль',
|
||||
className: 'topbar--profile user-profile-header',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню профиля',
|
||||
ariaLabel: 'Меню профиля',
|
||||
className: 'profile-head-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 250,
|
||||
items: [
|
||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
|
||||
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
|
||||
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'Профиль',
|
||||
className: 'topbar--root user-profile-header',
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
@@ -114,57 +30,21 @@ export function render({ navigate, chrome }) {
|
||||
|
||||
function renderProfile() {
|
||||
if (!card) return;
|
||||
const stats = card.stats || {};
|
||||
const official = card.accountRole === 'primary';
|
||||
const shining = card.shineStatus === 'shining';
|
||||
const fullName = [card.firstName, card.lastName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const displayName = fullName || card.login || login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = topbar.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login || login || 'Профиль';
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
|
||||
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
|
||||
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||
<div class="user-profile-avatar-slot"></div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-social-metrics">
|
||||
${friendsMetricHtml(stats)}
|
||||
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
|
||||
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
|
||||
</div>
|
||||
|
||||
<section class="user-profile-about-card" aria-label="О себе">
|
||||
<div class="user-profile-about-title">О себе</div>
|
||||
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
|
||||
</section>
|
||||
|
||||
${(contactsVisible || spiritualPath) ? `
|
||||
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
|
||||
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
|
||||
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
|
||||
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-actions" aria-label="Действия со своим профилем">
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
|
||||
</div>
|
||||
body.innerHTML = profileCardHtml({
|
||||
card,
|
||||
login,
|
||||
tilesHtml: [
|
||||
profileTileHtml({ icon: 'edit', label: 'Редактировать', attrs: 'data-self-profile-action="edit"' }),
|
||||
profileTileHtml({ icon: 'wallet', label: 'Кошелёк', attrs: 'data-self-profile-action="wallet"' }),
|
||||
profileTileHtml({ icon: 'settings', label: 'Настройки', attrs: 'data-self-profile-action="settings"' }),
|
||||
].join(''),
|
||||
}) + `
|
||||
<div class="nav-list profile-more-list">
|
||||
<button class="nav-row" type="button" data-profile-list="primary_given"><span class="nav-row__label">Подтверждённые мной аккаунты</span><span class="nav-row__hint">Аккаунты, за которые вы поручились</span></button>
|
||||
<button class="nav-row" type="button" data-profile-list="shine_given"><span class="nav-row__label">Отмеченные мной как сияющие</span></button>
|
||||
<button class="nav-row" type="button" data-self-profile-action="profiles"><span class="nav-row__label">Сменить профиль</span><span class="nav-row__hint">Другие аккаунты на этом устройстве</span></button>
|
||||
</div>`;
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
@@ -198,31 +78,10 @@ export function render({ navigate, chrome }) {
|
||||
if (action === 'edit') navigate('profile-edit-view');
|
||||
if (action === 'wallet') navigate('wallet-view');
|
||||
if (action === 'settings') navigate('settings-view');
|
||||
if (action === 'profiles') navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
|
||||
const detailButton = event.target.closest('[data-profile-detail]');
|
||||
if (!detailButton) return;
|
||||
const detailKind = detailButton.dataset.profileDetail;
|
||||
const detailPanel = body.querySelector('#profile-view-detail-panel');
|
||||
if (!detailPanel) return;
|
||||
|
||||
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
|
||||
const active = button === detailButton;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
|
||||
if (detailKind === 'spiritual-path') {
|
||||
detailPanel.innerHTML = spiritualPathDetailHtml(card);
|
||||
} else if (detailKind === 'contacts') {
|
||||
detailPanel.innerHTML = contactsDetailHtml(card);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
detailPanel.hidden = false;
|
||||
detailPanel.dataset.activeDetail = detailKind;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
closeAllSavedProfiles,
|
||||
closeSavedProfile,
|
||||
getSavedProfiles,
|
||||
prepareAddProfileLogin,
|
||||
state,
|
||||
switchToSavedProfile,
|
||||
} from '../state.js';
|
||||
|
||||
@@ -58,12 +58,12 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const closeAllButton = document.createElement('button');
|
||||
closeAllButton.type = 'button';
|
||||
closeAllButton.className = 'secondary-btn profiles-close-all';
|
||||
closeAllButton.textContent = 'Закрыть все профили';
|
||||
closeAllButton.className = 'destructive-btn profiles-close-all';
|
||||
closeAllButton.textContent = 'Выйти из всех профилей';
|
||||
closeAllButton.addEventListener('click', async () => {
|
||||
const profiles = getSavedProfiles();
|
||||
if (!profiles.length) return;
|
||||
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
|
||||
const confirmed = await confirmDialog({ title: 'Выйти из всех профилей?', text: 'Все профили на этом устройстве будут закрыты, откроется экран входа.', confirmLabel: 'Выйти', danger: true });
|
||||
if (!confirmed) return;
|
||||
closeAllButton.disabled = true;
|
||||
status.hidden = false;
|
||||
@@ -83,10 +83,8 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const renderList = () => {
|
||||
const profiles = getSavedProfiles();
|
||||
const active = profiles.find((item) => item.isActive);
|
||||
intro.textContent = profiles.length
|
||||
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
|
||||
: 'На устройстве нет сохранённых профилей.';
|
||||
intro.textContent = profiles.length ? '' : 'На устройстве нет сохранённых профилей.';
|
||||
intro.hidden = profiles.length > 0;
|
||||
closeAllButton.disabled = profiles.length === 0;
|
||||
list.innerHTML = '';
|
||||
|
||||
@@ -97,11 +95,11 @@ export function render({navigate, chrome}) {
|
||||
const select = document.createElement('button');
|
||||
select.type = 'button';
|
||||
select.className = 'profiles-select';
|
||||
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="profiles-active-badge">Сейчас открыт</span>' : ''}`;
|
||||
select.disabled = profile.isActive;
|
||||
select.addEventListener('click', async () => {
|
||||
if (profile.isActive) return;
|
||||
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
|
||||
const confirmed = await confirmDialog({ title: 'Переключить профиль?', text: `Откроется профиль «${profile.login}».`, confirmLabel: 'Переключить' });
|
||||
if (!confirmed) return;
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
@@ -118,16 +116,17 @@ export function render({navigate, chrome}) {
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'profiles-close';
|
||||
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
|
||||
close.setAttribute('aria-label', `Выйти из профиля ${profile.login}`);
|
||||
close.title = 'Выйти из профиля';
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', async () => {
|
||||
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
|
||||
const message = profile.isActive
|
||||
? (others.length
|
||||
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
|
||||
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
|
||||
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
|
||||
if (!window.confirm(message)) return;
|
||||
? 'Приложение переключится на следующий сохранённый профиль.'
|
||||
: 'После выхода откроется экран входа.')
|
||||
: 'Профиль будет закрыт на этом устройстве.';
|
||||
if (!await confirmDialog({ title: `Выйти из профиля «${profile.login}»?`, text: message, confirmLabel: 'Выйти', danger: true })) return;
|
||||
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
|
||||
@@ -247,10 +247,10 @@ function queueSeedFor(queueId) {
|
||||
|
||||
function styleInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
field.style.webkitTextFillColor = '#111111';
|
||||
field.style.caretColor = '#111111';
|
||||
field.style.backgroundColor = '#ffffff';
|
||||
field.style.color = 'var(--text-primary)';
|
||||
field.style.webkitTextFillColor = 'var(--text-primary)';
|
||||
field.style.caretColor = 'var(--accent)';
|
||||
field.style.backgroundColor = 'var(--surface)';
|
||||
}
|
||||
|
||||
function readTicketFromUrl() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../services/password-words.js';
|
||||
import { openRegistrationFaq } from './registration-faq-view.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
export const pageMeta = { id: 'register-view', title: 'Регистрация', showAppChrome: false };
|
||||
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
|
||||
|
||||
function normalizeLoginForTemporaryUiGuard(login) {
|
||||
@@ -132,7 +132,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
function updatePasswordLength() {
|
||||
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
|
||||
passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
|
||||
}
|
||||
|
||||
function setStatusMessage(message, kind = '') {
|
||||
@@ -425,7 +425,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
createTopBar({
|
||||
title: 'Зарегистрироваться',
|
||||
title: 'Регистрация',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
title: 'У кого хранятся ключи?',
|
||||
paragraphs: [
|
||||
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
|
||||
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
|
||||
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
|
||||
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
|
||||
],
|
||||
},
|
||||
@@ -26,11 +26,11 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
},
|
||||
{
|
||||
id: 'key-derivation',
|
||||
shortTitle: 'Деривация',
|
||||
shortTitle: 'Как пароль превращается в ключи',
|
||||
title: 'Как генерируются ключи и что делает пароль?',
|
||||
paragraphs: [
|
||||
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
|
||||
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.',
|
||||
'Уже из этого секрета детерминированно строятся четыре основных ключа: ключ восстановления, главный ключ, ключ публикаций и ключ устройства.',
|
||||
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
|
||||
],
|
||||
},
|
||||
@@ -39,9 +39,9 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
shortTitle: 'Три ключа',
|
||||
title: 'Зачем нужны три ключа?',
|
||||
paragraphs: [
|
||||
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
|
||||
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
|
||||
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
|
||||
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
|
||||
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
|
||||
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
|
||||
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
|
||||
],
|
||||
},
|
||||
@@ -78,9 +78,9 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
{
|
||||
id: 'first-server',
|
||||
shortTitle: 'Первый сервер',
|
||||
title: 'Что такое первый сервер SHiNE?',
|
||||
title: 'Что такое первый сервер Сияния?',
|
||||
paragraphs: [
|
||||
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
|
||||
'Первый сервер Сияния это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
|
||||
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
|
||||
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
|
||||
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
paragraphs: [
|
||||
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
|
||||
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
|
||||
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,7 +118,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
shortTitle: 'Кошелёк',
|
||||
title: 'Можно ли использовать такое устройство как кошелёк?',
|
||||
paragraphs: [
|
||||
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
|
||||
'Да. Идея Сияния в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
|
||||
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
|
||||
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
|
||||
],
|
||||
@@ -142,7 +142,6 @@ export function render({ navigate }) {
|
||||
const heroCard = document.createElement('div');
|
||||
heroCard.className = 'card stack registration-faq-hero';
|
||||
heroCard.innerHTML = `
|
||||
<div class="badge alt">Вопросы о регистрации</div>
|
||||
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
|
||||
`;
|
||||
|
||||
@@ -174,20 +173,23 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const topicsCard = document.createElement('div');
|
||||
topicsCard.className = 'card stack';
|
||||
topicsCard.className = 'stack';
|
||||
|
||||
const topicsLabel = document.createElement('p');
|
||||
topicsLabel.className = 'field-label';
|
||||
topicsLabel.className = 'nav-list__title';
|
||||
topicsLabel.textContent = 'Другие вопросы';
|
||||
|
||||
const topicsGrid = document.createElement('div');
|
||||
topicsGrid.className = 'registration-faq-grid';
|
||||
topicsGrid.className = 'nav-list';
|
||||
|
||||
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||
REGISTRATION_FAQ_TOPICS.filter((topic) => topic.id !== selectedTopic.id).forEach((topic) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
|
||||
button.className = 'nav-row';
|
||||
button.type = 'button';
|
||||
button.textContent = topic.shortTitle;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'nav-row__label';
|
||||
label.textContent = topic.title;
|
||||
button.append(label);
|
||||
button.addEventListener('click', () => {
|
||||
state.registrationHelp.selectedTopic = topic.id;
|
||||
navigate('registration-faq-view');
|
||||
|
||||
@@ -83,7 +83,7 @@ export function render({ navigate }) {
|
||||
const blockchainRow = createKeyInfo(
|
||||
blockchainToggle,
|
||||
'Ключ blockchain',
|
||||
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
|
||||
'Используется для подписи ваших действий и записей в блокчейне Сияния.',
|
||||
);
|
||||
|
||||
const deviceRow = createKeyInfo(
|
||||
|
||||
@@ -6,7 +6,7 @@ export const pageMeta = { id: 'server-settings-view', title: 'Серверы б
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
|
||||
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
@@ -27,13 +27,9 @@ export function render({navigate, chrome}) {
|
||||
const timers = new Map();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.className = 'settings-intro';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Серверы блокчейнов и публичных данных</p>
|
||||
<p class="meta-muted">
|
||||
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
|
||||
Эти настройки не меняют список личных серверов доступа пользователя.
|
||||
</p>
|
||||
<p class="meta-muted">Откуда приложение читает публичные данные: Solana, Сияние и Arweave. Личные серверы доступа здесь не меняются.</p>
|
||||
`;
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -138,7 +134,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const callTimeoutLabel = document.createElement('label');
|
||||
callTimeoutLabel.className = 'field-label';
|
||||
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
|
||||
callTimeoutLabel.textContent = 'Сколько ждать связи с сервером перед звонком (мс)';
|
||||
|
||||
const callTimeoutInput = document.createElement('input');
|
||||
callTimeoutInput.className = 'input';
|
||||
@@ -155,13 +151,13 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const callTimeoutHint = document.createElement('p');
|
||||
callTimeoutHint.className = 'meta-muted';
|
||||
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
|
||||
callTimeoutHint.textContent = 'Перед звонком приложение проверяет и при необходимости восстанавливает соединение с сервером. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
|
||||
|
||||
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
|
||||
body.append(callSettings);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.className = 'action-pair';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
@@ -190,7 +186,7 @@ export function render({navigate, chrome}) {
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
window.alert(
|
||||
'Текст для разработчиков: для SHiNE вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
|
||||
'Текст для разработчиков: для Сияния вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
|
||||
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
|
||||
import { openPaletteEditor } from '../components/palette-editor.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -39,7 +42,7 @@ export function render({navigate, chrome}) {
|
||||
const logoButton = document.createElement('button');
|
||||
logoButton.type = 'button';
|
||||
logoButton.className = 'settings-shine-logo-button';
|
||||
logoButton.setAttribute('aria-label', 'Логотип SHiNE');
|
||||
logoButton.setAttribute('aria-label', 'Логотип Сияния');
|
||||
logoButton.title = 'SHiNE';
|
||||
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
|
||||
|
||||
@@ -55,32 +58,65 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'stack settings-sections';
|
||||
const row = (id, label, hint) => `<button class="nav-row" type="button" id="${id}"><span class="nav-row__label">${label}</span>${hint ? `<span class="nav-row__hint">${hint}</span>` : ''}</button>`;
|
||||
card.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Сервер доступа</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы блокчейнов</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
<div class="card stack settings-theme-card">
|
||||
<strong id="settings-theme-label">Оформление</strong>
|
||||
<div class="tabs tabs--auto" id="settings-theme" role="radiogroup" aria-labelledby="settings-theme-label">
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="system">Авто</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="light">День</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="dark">Ночь</button>
|
||||
</div>
|
||||
<span class="meta-muted">«Авто» — как на устройстве.</span>
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
|
||||
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
|
||||
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
|
||||
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
|
||||
${row('settings-remote-addblock', 'Публикация через домашний сервер', 'Когда ключа публикаций нет на этом устройстве')}
|
||||
${row('settings-language', 'Язык / Language', 'Русский')}
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
<button class="nav-row nav-row--danger" type="button" id="settings-signout"><span class="nav-row__label">Выйти из аккаунта на этом устройстве</span></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const themeSwitch = card.querySelector('#settings-theme');
|
||||
const syncThemeSwitch = () => {
|
||||
const mode = getThemeMode();
|
||||
themeSwitch.querySelectorAll('[data-mode]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.mode === mode));
|
||||
});
|
||||
};
|
||||
syncThemeSwitch();
|
||||
|
||||
const LONG_PRESS_MS = 600;
|
||||
let longPressTimer = 0;
|
||||
let longPressFired = false;
|
||||
const cancelLongPress = () => { window.clearTimeout(longPressTimer); longPressTimer = 0; };
|
||||
themeSwitch.addEventListener('pointerdown', (event) => {
|
||||
const mode = event.target.closest('[data-mode]')?.dataset.mode;
|
||||
longPressFired = false;
|
||||
if (mode !== 'light' && mode !== 'dark') return;
|
||||
longPressTimer = window.setTimeout(() => {
|
||||
longPressFired = true;
|
||||
setThemeMode(mode);
|
||||
syncThemeSwitch();
|
||||
openPaletteEditor({ theme: mode });
|
||||
}, LONG_PRESS_MS);
|
||||
});
|
||||
['pointerup', 'pointerleave', 'pointercancel'].forEach((type) => themeSwitch.addEventListener(type, cancelLongPress));
|
||||
// На телефоне долгое нажатие иначе вызывает системное меню выделения.
|
||||
themeSwitch.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||
themeSwitch.addEventListener('click', (event) => {
|
||||
const mode = event.target.closest('[data-mode]')?.dataset.mode;
|
||||
if (!mode || longPressFired) return;
|
||||
setThemeMode(mode);
|
||||
syncThemeSwitch();
|
||||
});
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
@@ -93,9 +129,12 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||
);
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Выйти из аккаунта?',
|
||||
text: 'Вы выйдете на этом устройстве. Если сохранены другие профили, приложение переключится на следующий.',
|
||||
confirmLabel: 'Выйти',
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
signOutBtn.disabled = true;
|
||||
@@ -170,6 +209,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
screen.cleanup = () => {
|
||||
isDisposed = true;
|
||||
cancelLongPress();
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Ключи этого устройства' };
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
@@ -23,19 +23,18 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать ключи',
|
||||
title: 'Ключи этого устройства',
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'nav-list key-list';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
status.textContent = 'Загружаем сохранённые ключи...';
|
||||
card.append(status);
|
||||
|
||||
const renderField = (id, label) => {
|
||||
const renderField = (id, label, hint = '') => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'key-card stack';
|
||||
const eyeIcon = `
|
||||
@@ -53,8 +52,8 @@ export function render({navigate, chrome}) {
|
||||
`;
|
||||
row.innerHTML = `
|
||||
<div class="row">
|
||||
<span class="field-label">${label}</span>
|
||||
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
|
||||
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
|
||||
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
|
||||
</div>
|
||||
<div class="key-value key-value--compact" data-value="${id}">*****</div>
|
||||
`;
|
||||
@@ -64,9 +63,9 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
|
||||
card.append(
|
||||
renderField('root', 'root key (base58)'),
|
||||
renderField('blockchain', 'blockchain.key (base58)'),
|
||||
renderField('device', 'client key (base58)'),
|
||||
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
|
||||
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
|
||||
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
|
||||
);
|
||||
|
||||
const setMissingState = (id) => {
|
||||
@@ -91,8 +90,8 @@ export function render({navigate, chrome}) {
|
||||
valueEl.textContent = visible[id] ? keys[id] : '*****';
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = visible[id]
|
||||
? field?._eyeIcon || ''
|
||||
: field?._eyeOffIcon || '';
|
||||
? field?._eyeOffIcon || ''
|
||||
: field?._eyeIcon || '';
|
||||
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
|
||||
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
|
||||
};
|
||||
@@ -108,16 +107,6 @@ export function render({navigate, chrome}) {
|
||||
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'ghost-btn';
|
||||
closeButton.type = 'button';
|
||||
closeButton.textContent = 'Назад';
|
||||
closeButton.addEventListener('click', () => navigate('device-view'));
|
||||
actions.append(closeButton);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (!state.session.login || !state.session.storagePwdInMemory) {
|
||||
@@ -149,6 +138,6 @@ export function render({navigate, chrome}) {
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
})();
|
||||
|
||||
screen.append(card, actions);
|
||||
screen.append(status, card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ export function render({ navigate }) {
|
||||
loginButton.addEventListener('click', () => navigate('login-view'));
|
||||
|
||||
const registerButton = document.createElement('button');
|
||||
registerButton.className = 'shine-btn shine-btn--register';
|
||||
registerButton.className = 'secondary-btn shine-btn--register';
|
||||
registerButton.type = 'button';
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const languageButton = document.createElement('button');
|
||||
languageButton.className = 'shine-btn shine-btn--view';
|
||||
languageButton.className = 'text-btn start-language-link';
|
||||
languageButton.type = 'button';
|
||||
languageButton.textContent = 'Язык / Language';
|
||||
languageButton.addEventListener('click', () => {
|
||||
@@ -46,9 +46,9 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'shine-btn shine-btn--settings';
|
||||
settingsButton.className = 'text-btn start-settings-link';
|
||||
settingsButton.type = 'button';
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.textContent = 'Настройки входа';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, languageButton, settingsButton);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authService, setAuthError, setAuthInfo, state } from '../state.js';
|
||||
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' };
|
||||
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Вход с другого устройства' };
|
||||
|
||||
function setStatus(statusEl, message, kind = 'info') {
|
||||
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||
@@ -38,15 +38,14 @@ export function render({navigate, chrome}) {
|
||||
status.style.display = 'none';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'row';
|
||||
actions.style.flexWrap = 'wrap';
|
||||
actions.className = 'stack trusted-login-actions';
|
||||
|
||||
const enableToggleBtn = document.createElement('button');
|
||||
enableToggleBtn.className = 'primary-btn';
|
||||
enableToggleBtn.type = 'button';
|
||||
|
||||
const noPasswordBtn = document.createElement('button');
|
||||
noPasswordBtn.className = 'ghost-btn';
|
||||
noPasswordBtn.className = 'secondary-btn';
|
||||
noPasswordBtn.type = 'button';
|
||||
noPasswordBtn.textContent = 'Сделать вход без пароля';
|
||||
|
||||
@@ -77,6 +76,7 @@ export function render({navigate, chrome}) {
|
||||
busy = flag;
|
||||
enableToggleBtn.disabled = flag;
|
||||
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
|
||||
noPasswordBtn.hidden = !settings.enabled || !settings.hasPassword;
|
||||
savePasswordBtn.disabled = flag;
|
||||
passwordInput.disabled = flag;
|
||||
passwordConfirmInput.disabled = flag;
|
||||
@@ -87,6 +87,7 @@ export function render({navigate, chrome}) {
|
||||
enableToggleBtn.textContent = settings.enabled
|
||||
? 'Запретить вход через другое устройство'
|
||||
: 'Разрешить вход через другое устройство';
|
||||
enableToggleBtn.className = settings.enabled ? 'destructive-btn' : 'primary-btn';
|
||||
|
||||
actions.innerHTML = '';
|
||||
actions.append(enableToggleBtn);
|
||||
@@ -183,7 +184,7 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки входа через устройство',
|
||||
title: 'Вход с другого устройства',
|
||||
back: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}));
|
||||
screen.append(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authService, state } from '../state.js';
|
||||
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
|
||||
|
||||
export const pageMeta = { id: 'user', title: 'Профиль' };
|
||||
|
||||
@@ -24,68 +25,6 @@ function effectiveSocial(flags = {}) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
const numericValue = Number(value || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||
data-profile-list="${escapeHtml(kind)}"
|
||||
aria-label="${escapeHtml(label)}: ${numericValue}"
|
||||
>
|
||||
<span class="user-profile-metric-circle">${numericValue}</span>
|
||||
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function friendsMetricHtml(stats = {}) {
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric is-social-metric is-friends-combined"
|
||||
data-profile-list="friends"
|
||||
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
|
||||
>
|
||||
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
|
||||
<span class="user-profile-metric-label">Друзья</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function hasContacts(card) {
|
||||
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
|
||||
}
|
||||
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
if (!rows.length) {
|
||||
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||
}
|
||||
return rows.map(([label, value]) => `
|
||||
<div class="user-profile-contact-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<b>${escapeHtml(value)}</b>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function addIconHtml() {
|
||||
return `
|
||||
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<path d="M10.5 20.5 17 27l13-14" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function relationMenuHtml(flags = {}) {
|
||||
const current = effectiveSocial(flags);
|
||||
const rows = [
|
||||
@@ -107,7 +46,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const header = createTopBar({
|
||||
title: requestedLogin || 'Профиль',
|
||||
title: 'Профиль',
|
||||
back: { label: '←', onClick: () => navigateBack() },
|
||||
});
|
||||
header.classList.add('user-profile-header');
|
||||
@@ -186,60 +125,20 @@ export function render({ navigate, route, chrome }) {
|
||||
function renderProfile() {
|
||||
if (!card) return;
|
||||
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
|
||||
const stats = card.stats || {};
|
||||
const official = card.accountRole === 'primary';
|
||||
const shining = card.shineStatus === 'shining';
|
||||
const fullName = [card.firstName, card.lastName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const displayName = fullName || card.login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = header.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
|
||||
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
|
||||
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||
<div class="user-profile-avatar-slot"></div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-social-metrics">
|
||||
${friendsMetricHtml(stats)}
|
||||
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
|
||||
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
|
||||
</div>
|
||||
|
||||
<section class="user-profile-about-card" aria-label="О себе">
|
||||
<div class="user-profile-about-title">О себе</div>
|
||||
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
|
||||
</section>
|
||||
|
||||
${(contactsVisible || spiritualPath) ? `
|
||||
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
|
||||
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
|
||||
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
|
||||
|
||||
${!isSelf ? `
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-add-menu" hidden></div>
|
||||
<div class="user-profile-actions" aria-label="Действия с пользователем">
|
||||
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
|
||||
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
|
||||
</div>
|
||||
</div>` : ''}`;
|
||||
body.innerHTML = profileCardHtml({
|
||||
card,
|
||||
login: requestedLogin,
|
||||
isSelf,
|
||||
beforeTilesHtml: '<div class="user-profile-add-menu" hidden></div>',
|
||||
tilesHtml: isSelf ? '' : [
|
||||
profileTileHtml({ icon: 'user-plus', label: 'Добавить', attrs: 'data-profile-action="add" aria-haspopup="menu" aria-expanded="false"' }),
|
||||
profileTileHtml({ label: 'Связи', attrs: 'data-profile-action="links"', iconMarkup: `<img class="pf-tile-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">` }),
|
||||
profileTileHtml({ icon: 'message', label: 'Написать', attrs: 'data-profile-action="chat"' }),
|
||||
].join(''),
|
||||
});
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
avatarSlot?.append(renderUserAvatar({
|
||||
@@ -272,31 +171,6 @@ export function render({ navigate, route, chrome }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailButton = event.target.closest('[data-profile-detail]');
|
||||
if (detailButton) {
|
||||
const detailKind = detailButton.dataset.profileDetail;
|
||||
const detailPanel = body.querySelector('#user-profile-detail-panel');
|
||||
if (!detailPanel) return;
|
||||
|
||||
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
|
||||
const active = button === detailButton;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
|
||||
if (detailKind === 'spiritual-path') {
|
||||
detailPanel.innerHTML = spiritualPathDetailHtml(card);
|
||||
} else if (detailKind === 'contacts') {
|
||||
detailPanel.innerHTML = contactsDetailHtml(card);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
detailPanel.hidden = false;
|
||||
detailPanel.dataset.activeDetail = detailKind;
|
||||
return;
|
||||
}
|
||||
|
||||
const relationButton = event.target.closest('[data-relation-kind]');
|
||||
if (relationButton) {
|
||||
if (!selfLogin) {
|
||||
|
||||
@@ -531,10 +531,10 @@ export function render({navigate, chrome}) {
|
||||
|
||||
function styleSupportInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
field.style.webkitTextFillColor = '#111111';
|
||||
field.style.caretColor = '#111111';
|
||||
field.style.backgroundColor = '#ffffff';
|
||||
field.style.color = 'var(--text-primary)';
|
||||
field.style.webkitTextFillColor = 'var(--text-primary)';
|
||||
field.style.caretColor = 'var(--accent)';
|
||||
field.style.backgroundColor = 'var(--surface)';
|
||||
}
|
||||
|
||||
function renderSupportHub() {
|
||||
@@ -1283,55 +1283,28 @@ export function render({navigate, chrome}) {
|
||||
content.innerHTML = '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0 0 6px;">Кошелёк</h2>
|
||||
<p class="meta-muted">Выберите режим кошелька.</p>
|
||||
`;
|
||||
|
||||
const solanaBtn = document.createElement('button');
|
||||
solanaBtn.className = 'primary-btn';
|
||||
solanaBtn.style.width = '100%';
|
||||
solanaBtn.textContent = 'Solana кошелёк';
|
||||
solanaBtn.addEventListener('click', () => {
|
||||
void renderSolanaWallet();
|
||||
card.className = 'nav-list';
|
||||
const items = [
|
||||
['Solana кошелёк', 'Баланс SOL', () => renderSolanaWallet()],
|
||||
['Arweave кошелёк', 'Баланс AR', () => renderArweaveWallet()],
|
||||
['Предоплаченное место Сияния', 'Лимит, израсходовано и остаток', () => renderShineBlockchainWallet()],
|
||||
['Закрепление в Solana', 'Что закреплено в Solana и на сервере', () => renderSolanaPublishWallet()],
|
||||
['Поддержать проект Сияние', 'Билеты и справка', () => renderSupportHub()],
|
||||
];
|
||||
const buttons = items.map(([label, hint, open]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'nav-row';
|
||||
btn.innerHTML = `<span class="nav-row__label"></span><span class="nav-row__hint"></span>`;
|
||||
btn.querySelector('.nav-row__label').textContent = label;
|
||||
btn.querySelector('.nav-row__hint').textContent = hint;
|
||||
btn.addEventListener('click', () => { void open(); });
|
||||
return btn;
|
||||
});
|
||||
|
||||
const arweaveBtn = document.createElement('button');
|
||||
arweaveBtn.className = 'primary-btn';
|
||||
arweaveBtn.style.width = '100%';
|
||||
arweaveBtn.textContent = 'Arweave кошелёк';
|
||||
arweaveBtn.addEventListener('click', () => {
|
||||
void renderArweaveWallet();
|
||||
});
|
||||
|
||||
const shineBchBtn = document.createElement('button');
|
||||
shineBchBtn.className = 'primary-btn';
|
||||
shineBchBtn.style.width = '100%';
|
||||
shineBchBtn.textContent = 'Предоплаченное место Сияния';
|
||||
shineBchBtn.addEventListener('click', () => {
|
||||
void renderShineBlockchainWallet();
|
||||
});
|
||||
|
||||
const solanaPublishBtn = document.createElement('button');
|
||||
solanaPublishBtn.className = 'primary-btn';
|
||||
solanaPublishBtn.style.width = '100%';
|
||||
solanaPublishBtn.textContent = 'Закрепление в Solana';
|
||||
solanaPublishBtn.addEventListener('click', () => {
|
||||
void renderSolanaPublishWallet();
|
||||
});
|
||||
|
||||
const supportBtn = document.createElement('button');
|
||||
supportBtn.className = 'primary-btn';
|
||||
supportBtn.style.width = '100%';
|
||||
supportBtn.textContent = 'Поддержать проект Сияние';
|
||||
supportBtn.addEventListener('click', () => {
|
||||
void renderSupportHub();
|
||||
});
|
||||
|
||||
const [solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn] = buttons;
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
|
||||
content.append(card);
|
||||
setStatus('Выберите тип кошелька.');
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function renderShineBlockchainWallet() {
|
||||
@@ -2051,7 +2024,7 @@ export function render({navigate, chrome}) {
|
||||
helpCard.innerHTML = `
|
||||
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
|
||||
<p class="meta-muted" style="margin-top:8px;">
|
||||
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
|
||||
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
|
||||
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
|
||||
</p>
|
||||
`;
|
||||
|
||||
+10
-1
@@ -476,7 +476,17 @@ export function navigateBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Чужой профиль открывают из чатов, связей и каналов — подсвечиваем вкладку, откуда пришли.
|
||||
let lastToolbarTab = 'messages-list';
|
||||
|
||||
export function resolveToolbarActive(pageId) {
|
||||
if (pageId === 'user') return lastToolbarTab;
|
||||
const tab = resolveToolbarTab(pageId);
|
||||
lastToolbarTab = tab;
|
||||
return tab;
|
||||
}
|
||||
|
||||
function resolveToolbarTab(pageId) {
|
||||
if (
|
||||
pageId === 'messages-list'
|
||||
|| pageId === 'channels-list'
|
||||
@@ -510,7 +520,6 @@ export function resolveToolbarActive(pageId) {
|
||||
) return 'profile-view';
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||
if (pageId === 'user') return 'messages-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
|
||||
@@ -177,14 +177,20 @@ function stopVideos(root) {
|
||||
});
|
||||
}
|
||||
|
||||
function createDownloadLink(url, label = 'Скачать') {
|
||||
function createDownloadLink(url, label = 'Скачать', { iconOnly = false } = {}) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-download';
|
||||
link.className = `message-attachment-download${iconOnly ? ' message-attachment-download--icon' : ''}`;
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.download = '';
|
||||
link.textContent = label;
|
||||
if (iconOnly) {
|
||||
link.setAttribute('aria-label', label);
|
||||
link.title = label;
|
||||
link.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M6 10l6 6 6-6M5 20h14"/></svg>';
|
||||
} else {
|
||||
link.textContent = label;
|
||||
}
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
@@ -287,6 +293,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
|
||||
|
||||
const bindImageLayout = (img) => {
|
||||
img.addEventListener('load', () => {
|
||||
frame.classList.add('is-loaded');
|
||||
const naturalWidth = Number(img.naturalWidth || 0);
|
||||
const naturalHeight = Number(img.naturalHeight || 0);
|
||||
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
|
||||
@@ -326,13 +333,14 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
video.addEventListener('loadeddata', () => frame.classList.add('is-loaded'), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(video, play);
|
||||
}
|
||||
|
||||
frame.append(createDownloadLink(url));
|
||||
frame.append(createDownloadLink(url, 'Скачать', { iconOnly: true }));
|
||||
return frame;
|
||||
}
|
||||
|
||||
|
||||
@@ -1845,7 +1845,7 @@ export class AuthService {
|
||||
|
||||
const ansTags = [{ name: 'App', value: 'test5590' }];
|
||||
const cleanChannelSlug = String(channelSlug || '').trim();
|
||||
if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug });
|
||||
if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
|
||||
|
||||
const { response, blockchainName } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Состояние чтения живёт только в текущей вкладке и разделено по аккаунтам.
|
||||
const positions = new Map();
|
||||
export function rememberChannelPosition(key) {
|
||||
positions.set(key, document.getElementById('app-screen')?.scrollTop || 0);
|
||||
}
|
||||
export function readChannelPosition(key) { return positions.get(key); }
|
||||
export function restoreChannelPosition(value) {
|
||||
const root = document.getElementById('app-screen');
|
||||
if (root && Number.isFinite(value)) root.scrollTop = value;
|
||||
}
|
||||
@@ -28,6 +28,56 @@ function pickUnit(seconds) {
|
||||
return ['year', Math.round(years)];
|
||||
}
|
||||
|
||||
// Время поста внутри дня: «14:32». День показывается отдельной плашкой.
|
||||
export function formatClockTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(ts));
|
||||
}
|
||||
|
||||
// Плашка дня в ленте: «Сегодня», «Вчера», «24 сентября», «24 сентября 2025».
|
||||
export function formatDayLabel(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
const today = new Date();
|
||||
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const diffDays = Math.round((startOf(today) - startOf(dt)) / 86400000);
|
||||
if (diffDays === 0) return 'Сегодня';
|
||||
if (diffDays === 1) return 'Вчера';
|
||||
const sameYear = dt.getFullYear() === today.getFullYear();
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(dt);
|
||||
}
|
||||
|
||||
export function dayKey(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
|
||||
}
|
||||
|
||||
// Время в строках списков (чаты, каналы) — коротко, как в мессенджерах:
|
||||
// сегодня «21:41», вчера «вчера», на этой неделе «пт», в этом году «24.09», раньше «24.09.25».
|
||||
export function formatListTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
if (ts >= startOfToday) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(dt);
|
||||
}
|
||||
const dayMs = 86400000;
|
||||
if (ts >= startOfToday - dayMs) return 'вчера';
|
||||
if (ts >= startOfToday - 6 * dayMs) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short' }).format(dt);
|
||||
}
|
||||
if (dt.getFullYear() === now.getFullYear()) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(dt);
|
||||
}
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(dt);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '—';
|
||||
@@ -39,6 +89,8 @@ export function formatRelativeTime(timestampMs) {
|
||||
const ageSeconds = Math.max(0, (now - ts) / 1000);
|
||||
const ageDays = ageSeconds / 86400;
|
||||
|
||||
if (ageSeconds < 60) return 'только что';
|
||||
|
||||
if (ageDays < 7) {
|
||||
const [unit, value] = pickUnit(diffSeconds);
|
||||
if (rtf) return rtf.format(value, unit);
|
||||
|
||||
@@ -43,7 +43,7 @@ function wsUrlToHttpBase(wsUrl = '') {
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
|
||||
throw new Error('Не удалось определить HTTP-адрес сервера Сияния');
|
||||
}
|
||||
parsed.pathname = '/';
|
||||
parsed.search = '';
|
||||
@@ -609,7 +609,7 @@ function suggestedPickerTypes(attachment) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot >= 0 ? name.slice(dot) : '';
|
||||
return [{
|
||||
description: 'Файл SHiNE',
|
||||
description: 'Файл Сияния',
|
||||
accept: { [mime]: extension ? [extension] : [] },
|
||||
}];
|
||||
}
|
||||
@@ -692,7 +692,7 @@ export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = n
|
||||
}
|
||||
|
||||
export async function buildDmFileTorrentV2(attachment = {}) {
|
||||
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2');
|
||||
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов Сияния v2');
|
||||
const context = await loadV2RootManifest(attachment);
|
||||
const pieceLayer = [];
|
||||
try {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function makeKeyTransferText({ login, keys }) {
|
||||
export function parseKeyTransferText(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw.startsWith(TRANSFER_PREFIX)) {
|
||||
throw new Error('Это не QR-код переноса ключей SHiNE');
|
||||
throw new Error('Это не QR-код переноса ключей Сияния');
|
||||
}
|
||||
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
|
||||
const payload = JSON.parse(json);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function resolveShineServerByServerLogin({ serverLogin, solanaEndpo
|
||||
solanaEndpoint,
|
||||
});
|
||||
if (!parsed?.isServer) {
|
||||
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
|
||||
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер Сияния.`);
|
||||
}
|
||||
const serverAddress = normalizeHostLike(parsed?.serverAddress || '');
|
||||
if (!serverAddress) {
|
||||
|
||||
@@ -695,7 +695,7 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
|
||||
const enc = new TextEncoder();
|
||||
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
|
||||
const accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
|
||||
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`);
|
||||
if (!accountInfo?.data) throw new Error(`Запись аккаунта «${cleanLogin}» в Solana не найдена`);
|
||||
return {
|
||||
...parseShineUserPda(accountInfo.data),
|
||||
userPda: userPda.toBase58(),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
|
||||
// Выбранная палитра и личные правки цветов (для дня и ночи отдельно).
|
||||
const PALETTE_KEY = 'shine-ui-palette-v1';
|
||||
// Итоговые значения для каждой темы: их читает скрипт в index.html до первой отрисовки,
|
||||
// чтобы при запуске не мелькала палитра по умолчанию.
|
||||
const APPLIED_KEY = 'shine-ui-palette-applied-v1';
|
||||
const MODES = new Set(['system', 'light', 'dark']);
|
||||
let sessionMode = null;
|
||||
|
||||
// Роли цветов — тот же контракт, что в docs/UI-Design/DESIGN.md (раздел 4.1) и styles/main.css.
|
||||
export const PALETTE_ROLES = [
|
||||
{ id: 'background', label: 'Фон' },
|
||||
{ id: 'surface', label: 'Поверхности: поля, меню, карточки' },
|
||||
{ id: 'surface-selected', label: 'Выбранное и лёгкое выделение' },
|
||||
{ id: 'text-primary', label: 'Основной текст' },
|
||||
{ id: 'text-secondary', label: 'Вторичный текст: время, подсказки' },
|
||||
{ id: 'border-subtle', label: 'Разделители' },
|
||||
{ id: 'border-control', label: 'Рамки полей и кнопок' },
|
||||
{ id: 'accent', label: 'Акцент: главные кнопки, ссылки' },
|
||||
{ id: 'on-accent', label: 'Текст на акцентной кнопке' },
|
||||
{ id: 'reaction-active', label: 'Поставленный лайк' },
|
||||
{ id: 'danger', label: 'Ошибки и удаление' },
|
||||
{ id: 'success', label: 'Успех' },
|
||||
{ id: 'warning', label: 'Предупреждение' },
|
||||
];
|
||||
|
||||
export const PALETTE_PRESETS = {
|
||||
club: {
|
||||
label: 'Индиго',
|
||||
dark: {
|
||||
background: '#12141f', surface: '#1c2031', 'surface-selected': '#272c45',
|
||||
'text-primary': '#eceefa', 'text-secondary': '#a0a6c2',
|
||||
'border-subtle': '#343a55', 'border-control': '#6d7396',
|
||||
accent: '#a9b4ff', 'on-accent': '#151a3d', 'reaction-active': '#ff9bb3',
|
||||
danger: '#ffa7a3', success: '#8fdab4', warning: '#e8c585',
|
||||
},
|
||||
light: {
|
||||
background: '#eef0f5', surface: '#ffffff', 'surface-selected': '#dde3f5',
|
||||
'text-primary': '#1e2233', 'text-secondary': '#5c6279',
|
||||
'border-subtle': '#d5d9e5', 'border-control': '#8a90a8',
|
||||
accent: '#4c5caa', 'on-accent': '#ffffff', 'reaction-active': '#c02a55',
|
||||
danger: '#b3261e', success: '#2c6b4a', warning: '#7d5a10',
|
||||
},
|
||||
},
|
||||
shine: {
|
||||
label: 'Бирюза',
|
||||
dark: {
|
||||
background: '#101b20', surface: '#17272d', 'surface-selected': '#213b3a',
|
||||
'text-primary': '#e8f2f1', 'text-secondary': '#98adaf',
|
||||
'border-subtle': '#2a3b40', 'border-control': '#71888a',
|
||||
accent: '#94e1ce', 'on-accent': '#102c27', 'reaction-active': '#f29aab',
|
||||
danger: '#ffaba8', success: '#94e1ce', warning: '#e6c184',
|
||||
},
|
||||
light: {
|
||||
background: '#faf7f0', surface: '#fffdf8', 'surface-selected': '#efe6d6',
|
||||
'text-primary': '#302c25', 'text-secondary': '#706658',
|
||||
'border-subtle': '#e6dfd1', 'border-control': '#978b79',
|
||||
accent: '#93511e', 'on-accent': '#fffaf3', 'reaction-active': '#a43350',
|
||||
danger: '#b13135', success: '#346a48', warning: '#845b14',
|
||||
},
|
||||
},
|
||||
mono: {
|
||||
label: 'Монохром',
|
||||
dark: {
|
||||
background: '#111212', surface: '#1c1d1d', 'surface-selected': '#2c2d2d',
|
||||
'text-primary': '#f3f3f1', 'text-secondary': '#a3a3a0',
|
||||
'border-subtle': '#2a2b2b', 'border-control': '#6f706e',
|
||||
accent: '#f3f3f1', 'on-accent': '#111212', 'reaction-active': '#ff8a9a',
|
||||
danger: '#ff9d97', success: '#9fd8a8', warning: '#e8c37e',
|
||||
},
|
||||
light: {
|
||||
background: '#ffffff', surface: '#f6f6f5', 'surface-selected': '#ebebea',
|
||||
'text-primary': '#141414', 'text-secondary': '#626262',
|
||||
'border-subtle': '#e4e4e2', 'border-control': '#8c8c8a',
|
||||
accent: '#141414', 'on-accent': '#ffffff', 'reaction-active': '#d0214a',
|
||||
danger: '#b3261e', success: '#2f6b3b', warning: '#7d5a10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET = 'club';
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function normalizeMode(value) {
|
||||
const mode = String(value || '').trim().toLowerCase();
|
||||
return MODES.has(mode) ? mode : 'system';
|
||||
}
|
||||
|
||||
function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || 'null');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// В приватном режиме палитра всё равно действует до закрытия страницы.
|
||||
}
|
||||
}
|
||||
|
||||
function cleanOverrides(raw) {
|
||||
const out = {};
|
||||
for (const role of PALETTE_ROLES) {
|
||||
const value = String(raw?.[role.id] || '').trim();
|
||||
if (HEX_COLOR.test(value)) out[role.id] = value.toLowerCase();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let sessionPalette = null;
|
||||
|
||||
export function getPaletteSettings() {
|
||||
if (sessionPalette) return sessionPalette;
|
||||
const raw = readJson(PALETTE_KEY) || {};
|
||||
const preset = PALETTE_PRESETS[raw.preset] ? raw.preset : DEFAULT_PRESET;
|
||||
return {
|
||||
preset,
|
||||
custom: { dark: cleanOverrides(raw.custom?.dark), light: cleanOverrides(raw.custom?.light) },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) {
|
||||
const theme = resolvedTheme === 'light' ? 'light' : 'dark';
|
||||
return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] };
|
||||
}
|
||||
|
||||
function isDefaultPalette(settings) {
|
||||
return settings.preset === DEFAULT_PRESET
|
||||
&& !Object.keys(settings.custom.dark).length
|
||||
&& !Object.keys(settings.custom.light).length;
|
||||
}
|
||||
|
||||
function applyPaletteVars(resolvedTheme) {
|
||||
const root = document.documentElement;
|
||||
const settings = getPaletteSettings();
|
||||
for (const role of PALETTE_ROLES) root.style.removeProperty(`--${role.id}`);
|
||||
root.style.removeProperty('--focus-ring');
|
||||
// Палитра по умолчанию живёт в styles/main.css — inline-переменные не нужны.
|
||||
if (isDefaultPalette(settings)) return;
|
||||
const colors = resolvePalette(resolvedTheme, settings);
|
||||
for (const [role, value] of Object.entries(colors)) root.style.setProperty(`--${role}`, value);
|
||||
root.style.setProperty('--focus-ring', colors.accent);
|
||||
}
|
||||
|
||||
export function setPaletteSettings(next) {
|
||||
const settings = {
|
||||
preset: PALETTE_PRESETS[next?.preset] ? next.preset : DEFAULT_PRESET,
|
||||
custom: { dark: cleanOverrides(next?.custom?.dark), light: cleanOverrides(next?.custom?.light) },
|
||||
};
|
||||
sessionPalette = settings;
|
||||
if (isDefaultPalette(settings)) {
|
||||
writeJson(PALETTE_KEY, null);
|
||||
writeJson(APPLIED_KEY, null);
|
||||
} else {
|
||||
writeJson(PALETTE_KEY, settings);
|
||||
writeJson(APPLIED_KEY, { dark: resolvePalette('dark', settings), light: resolvePalette('light', settings) });
|
||||
}
|
||||
return applyThemeMode();
|
||||
}
|
||||
|
||||
export function exportPalette() {
|
||||
const settings = getPaletteSettings();
|
||||
return JSON.stringify({
|
||||
preset: settings.preset,
|
||||
dark: resolvePalette('dark', settings),
|
||||
light: resolvePalette('light', settings),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export function importPalette(text) {
|
||||
const data = JSON.parse(String(text || ''));
|
||||
const preset = PALETTE_PRESETS[data?.preset] ? data.preset : DEFAULT_PRESET;
|
||||
const base = PALETTE_PRESETS[preset];
|
||||
const diff = (theme) => {
|
||||
const colors = cleanOverrides(data?.[theme]);
|
||||
return Object.fromEntries(Object.entries(colors).filter(([role, value]) => base[theme][role] !== value));
|
||||
};
|
||||
return setPaletteSettings({ preset, custom: { dark: diff('dark'), light: diff('light') } });
|
||||
}
|
||||
|
||||
export function getThemeMode() {
|
||||
if (sessionMode !== null) return sessionMode;
|
||||
try {
|
||||
return normalizeMode(localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveThemeMode(mode = getThemeMode()) {
|
||||
const normalized = normalizeMode(mode);
|
||||
if (normalized !== 'system') return normalized;
|
||||
return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function applyThemeMode(mode = getThemeMode()) {
|
||||
const normalized = normalizeMode(mode);
|
||||
const resolved = resolveThemeMode(normalized);
|
||||
document.documentElement.dataset.themeMode = normalized;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
applyPaletteVars(resolved);
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', resolvePalette(resolved).background);
|
||||
return { mode: normalized, resolved };
|
||||
}
|
||||
|
||||
export function setThemeMode(mode) {
|
||||
const normalized = normalizeMode(mode);
|
||||
sessionMode = normalized;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
// В приватном режиме тема всё равно применяется до закрытия страницы.
|
||||
}
|
||||
return applyThemeMode(normalized);
|
||||
}
|
||||
|
||||
export function watchSystemTheme() {
|
||||
const media = window.matchMedia?.('(prefers-color-scheme: light)');
|
||||
if (!media) return () => {};
|
||||
const onChange = () => {
|
||||
if (getThemeMode() === 'system') applyThemeMode('system');
|
||||
};
|
||||
media.addEventListener?.('change', onChange);
|
||||
return () => media.removeEventListener?.('change', onChange);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const profileFieldDefs = [
|
||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
|
||||
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
|
||||
{ key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' },
|
||||
{ key: 'web', readKeys: ['web'], label: 'Ссылки', placeholder: 'Сайт, профиль или другая ссылка' },
|
||||
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
|
||||
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
|
||||
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Static sites
|
||||
|
||||
Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.
|
||||
|
||||
Все содержимое этой папки деплоится вместе с `shine-UI`, потому что общий UI deploy синхронизирует весь каталог `shine-UI/` в Caddy web root. На текущей схеме отдельная перенастройка Caddy для каждого нового сайта не нужна: Caddy сначала отдает существующие файлы из UI root, а уже потом делает fallback на основной `index.html`.
|
||||
|
||||
## URL
|
||||
|
||||
Если UI задеплоен на `https://t2.shineup.me`, то эта папка доступна как:
|
||||
|
||||
```text
|
||||
https://t2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
Текущие подпапки:
|
||||
|
||||
```text
|
||||
/static-sites/design-examples/channels-v1/
|
||||
/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Для production домен меняется на нужный хост, например:
|
||||
|
||||
```text
|
||||
https://shineup.me/static-sites/
|
||||
https://server2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
## Как добавлять новый временный сайт
|
||||
|
||||
1. Создать подпапку внутри `shine-UI/static-sites/`.
|
||||
2. Положить туда `index.html` и связанные файлы.
|
||||
3. Выполнить обычный UI deploy нужного контура через `deploy/scripts/*_ui.sh`.
|
||||
|
||||
После этого сайт будет доступен по URL:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/<folder>/
|
||||
```
|
||||
|
||||
## Arweave viewer
|
||||
|
||||
Постоянная папка для viewer:
|
||||
|
||||
```text
|
||||
shine-UI/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Когда появляется новая версия viewer, можно заменить содержимое этой папки новой версией и выполнить обычный UI deploy. URL останется прежним:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user