Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions repostexchange-companion/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Repost Companion — Local Queue

A small Chrome Manifest V3 extension for keeping a local listening checklist and a record of reposts you complete yourself.

## What it does

- Keeps a queue of track titles and artist names on this device.
- Lets you mark a track listened to, then log the repost after you manually submit it.
- Shows progress toward a 20-repost local calendar-day target and the rolling 10-per-12-hour limit.
- Stores the queue and completion times in `chrome.storage.local`.

Repost Companion never opens or reads RepostExchange or SoundCloud, controls playback, clicks repost buttons, writes comments, or runs in the background. Use it as a checklist beside the site; you make each platform decision and action yourself. The rolling limit reflects the [RepostExchange terms](https://repostexchange.com/terms-of-use); check the terms for updates.

## Install locally

1. Open `chrome://extensions` in Chrome.
2. Turn on **Developer mode**.
3. Choose **Load unpacked** and select this `repostexchange-companion` folder.
4. Pin **Repost Companion** to the toolbar.

## Use

Add a track and artist to your queue. Listen on RepostExchange yourself, then mark it listened. If you choose to repost it, submit the repost on RepostExchange yourself and only then select **I reposted it** in the extension. The local queue and count will update.

Run the logic tests with `node --test tests/core.test.cjs`. Build the Chrome Web Store ZIP with `./scripts/package.sh`.

See `store-listing.md` for the proposed listing and privacy disclosures.
Binary file not shown.
Binary file added repostexchange-companion/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added repostexchange-companion/icons/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added repostexchange-companion/icons/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added repostexchange-companion/icons/icon-master.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions repostexchange-companion/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"manifest_version": 3,
"name": "Repost Companion — Local Queue",
"version": "1.1.0",
"description": "Keep a local manual listening checklist and repost progress log.",
"permissions": ["storage"],
"action": {
"default_title": "Repost Companion",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
91 changes: 91 additions & 0 deletions repostexchange-companion/popup.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
:root {
color-scheme: light;
--ink: #172724;
--muted: #667772;
--soft: #f4f6f3;
--line: #e5eae5;
--green: #155f4b;
--green-dark: #104c3c;
--lime: #c6f279;
--amber-bg: #fff3e8;
--white: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}

* { box-sizing: border-box; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; }

body { width: 410px; min-height: 580px; max-height: 640px; margin: 0; overflow-y: auto; background: var(--soft); color: var(--ink); }
.app-shell { padding: 19px 20px 16px; }
.topbar { display: flex; align-items: center; gap: 11px; padding-bottom: 18px; }
.brand-mark { width: 39px; height: 39px; border-radius: 13px; display: flex; align-items: center; justify-content: center; gap: 2px; background: var(--green); }
.brand-mark span { width: 3px; border-radius: 4px; background: var(--lime); }
.brand-mark span:nth-child(1), .brand-mark span:nth-child(5) { height: 8px; }
.brand-mark span:nth-child(2), .brand-mark span:nth-child(4) { height: 15px; }
.brand-mark span:nth-child(3) { height: 21px; }
.brand-copy { flex: 1; min-width: 0; }
.eyebrow { margin: 0 0 4px; color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .12em; }
h1, h2, p { margin-top: 0; }
h1 { margin: 0; font-size: 16px; letter-spacing: -.025em; }
.local-badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #dce5dd; border-radius: 99px; padding: 5px 8px; color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .08em; }
.local-dot, .status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #4c9a69; }

.progress-section { border: 1px solid var(--line); border-radius: 15px; padding: 14px; background: var(--white); box-shadow: 0 3px 10px rgba(27, 53, 43, .025); }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.section-heading h2 { margin: 0; font-size: 17px; letter-spacing: -.035em; }
.date-label { padding-bottom: 1px; color: var(--muted); font-size: 11px; }
.progress-card { margin-top: 13px; }
.progress-topline { display: flex; justify-content: space-between; align-items: baseline; }
.progress-topline strong { font-size: 25px; letter-spacing: -.06em; }
.count-total { color: #87958f; font-size: 13px; }
.progress-caption { color: var(--muted); font-size: 11px; }
.progress-track { height: 7px; margin-top: 8px; overflow: hidden; border-radius: 9px; background: #edf1ed; }
.progress-track span { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--green); transition: width .2s ease; }
.rolling-row { display: flex; align-items: center; justify-content: space-between; margin-top: 11px; color: var(--muted); font-size: 10px; }
.rolling-row > span { display: flex; align-items: center; gap: 6px; }
.rolling-row strong { color: var(--ink); font-size: 10px; }
.status-dot.is-limit { background: #dc7a2d; }
.limit-banner { margin-top: 10px; border: 1px solid #f3d6bc; border-radius: 10px; padding: 9px 11px; background: var(--amber-bg); color: #874916; font-size: 11px; line-height: 1.45; }

.form-section, .queue-section, .history-section { margin-top: 19px; }
.form-heading, .queue-heading, .history-heading { margin-bottom: 10px; }
.step-mark { color: var(--green); font-size: 11px; font-weight: 750; }
.step-mark i { color: #99aaa2; font-style: normal; font-weight: 500; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.field { display: flex; flex-direction: column; gap: 6px; color: #374943; font-size: 10px; font-weight: 700; }
.field input { width: 100%; height: 36px; border: 1px solid #dfe6df; border-radius: 9px; outline: none; padding: 0 10px; background: var(--white); color: var(--ink); font-size: 12px; font-weight: 450; transition: border-color .15s, box-shadow .15s; }
.field input:focus { border-color: #7bad93; box-shadow: 0 0 0 3px rgba(59, 134, 96, .11); }
.field input::placeholder { color: #a2aea8; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 34px; border: 0; border-radius: 9px; padding: 0 11px; font-size: 10px; font-weight: 700; transition: transform .12s, background .12s, opacity .12s; }
.button:not(:disabled):active { transform: translateY(1px); }
.button-primary { width: 100%; margin-top: 10px; background: var(--green); color: white; }
.button-primary:not(:disabled):hover { background: var(--green-dark); }
.button-primary:disabled, .button-log:disabled { opacity: .46; }
.manual-note { margin: 8px 0 0; color: #748078; font-size: 9px; line-height: 1.4; }

.history-total { padding-bottom: 1px; color: #8a9791; font-size: 10px; white-space: nowrap; }
.queue-list, .history-list { display: flex; flex-direction: column; gap: 7px; }
.queue-item, .history-item { display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 1px solid var(--line); border-radius: 10px; padding: 9px; background: white; }
.queue-copy, .history-copy { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.queue-copy strong, .history-copy strong { overflow: hidden; color: #2c3e36; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.queue-copy span, .history-copy span { color: #89958f; font-size: 9px; }
.queue-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.button-step { min-height: 29px; padding: 0 8px; background: #e6f2e9; color: #24533d; }
.button-step:not(:disabled):hover { background: #d8ebdd; }
.button-step:disabled { opacity: .45; }
.button-log { background: var(--lime); color: #21341f; }
.button-log:not(:disabled):hover { background: #b7e768; }
.button-remove, .remove-entry { border: 0; background: transparent; color: #8e9b94; font-size: 9px; }
.button-remove:hover, .remove-entry:hover { color: #a14831; }
.history-heading h2 { font-size: 15px; }
.empty-state { margin: 0; border: 1px dashed #dbe2dc; border-radius: 10px; padding: 12px; color: #829089; font-size: 10px; line-height: 1.4; text-align: center; }
.footer-note { display: flex; align-items: center; justify-content: center; gap: 5px; margin-top: 16px; color: #87938d; font-size: 9px; }
.lock-mark { color: var(--green); font-size: 11px; }
.toast { position: fixed; right: 16px; bottom: 12px; left: 16px; z-index: 2; border-radius: 9px; padding: 9px 10px; transform: translateY(8px); background: #203c31; color: white; font-size: 10px; text-align: center; opacity: 0; pointer-events: none; transition: opacity .16s, transform .16s; }
.toast.is-visible { transform: translateY(0); opacity: 1; }
[hidden] { display: none !important; }
78 changes: 78 additions & 0 deletions repostexchange-companion/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Repost Companion</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div class="brand-mark" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
<div class="brand-copy">
<p class="eyebrow">YOUR LOCAL CAMPAIGN DESK</p>
<h1>Repost Companion</h1>
</div>
<span class="local-badge"><span class="local-dot"></span>LOCAL</span>
</header>

<section class="progress-section" aria-label="Campaign progress">
<div class="section-heading">
<div><p class="eyebrow">TODAY</p><h2>Your pace</h2></div>
<span id="today-label" class="date-label"></span>
</div>
<div class="progress-card">
<div class="progress-topline">
<div><strong id="daily-count">0</strong><span class="count-total"> / 20</span></div>
<span id="daily-status" class="progress-caption">reposts logged</span>
</div>
<div class="progress-track" role="progressbar" aria-label="Daily target progress" aria-valuemin="0" aria-valuemax="20" aria-valuenow="0"><span id="daily-fill"></span></div>
<div class="rolling-row">
<span><span class="status-dot" id="rolling-dot"></span><span id="rolling-message">12-hour window clear</span></span>
<strong id="rolling-count">0 / 10</strong>
</div>
</div>
</section>

<div id="limit-banner" class="limit-banner" role="status" hidden></div>

<section class="form-section" aria-labelledby="track-heading">
<div class="section-heading form-heading">
<div><p class="eyebrow">BUILD YOUR QUEUE</p><h2 id="track-heading">Add a track</h2></div>
<span class="step-mark">01 <i>/ 02</i></span>
</div>
<div class="field-grid">
<label class="field"><span>Track title</span><input id="track-title" type="text" maxlength="160" autocomplete="off" placeholder="Name of the track"></label>
<label class="field"><span>Artist</span><input id="artist-name" type="text" maxlength="120" autocomplete="off" placeholder="Artist name"></label>
</div>
<button id="add-button" class="button button-primary" type="button" disabled><span>Add to queue</span><span aria-hidden="true">+</span></button>
<p class="manual-note">You control the listening and reposting on RepostExchange. Mark each step here after you do it.</p>
</section>

<section class="queue-section" aria-labelledby="queue-heading">
<div class="section-heading queue-heading">
<div><p class="eyebrow">YOUR MANUAL CHECKLIST</p><h2 id="queue-heading">Up next</h2></div>
<span id="queue-count" class="history-total">0 queued</span>
</div>
<div id="queue-list" class="queue-list"></div>
<p id="empty-queue" class="empty-state">Add a track. You’ll play it and submit any repost yourself.</p>
</section>

<section class="history-section" aria-labelledby="history-heading">
<div class="section-heading history-heading">
<div><p class="eyebrow">YOUR LOCAL LOG</p><h2 id="history-heading">Completed</h2></div>
<span id="history-count" class="history-total">0 saved</span>
</div>
<div id="history-list" class="history-list"></div>
<p id="empty-history" class="empty-state">Manually completed reposts will appear here.</p>
</section>

<footer class="footer-note"><span class="lock-mark" aria-hidden="true">◈</span> Nothing leaves this browser.</footer>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</main>
<script src="src/core.js"></script>
<script src="src/popup.js"></script>
</body>
</html>
12 changes: 12 additions & 0 deletions repostexchange-companion/scripts/package.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu

ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).version)' "$ROOT_DIR/manifest.json")
DIST_DIR="$ROOT_DIR/dist"
OUTPUT="$DIST_DIR/repost-companion-$VERSION.zip"

mkdir -p "$DIST_DIR"
cd "$ROOT_DIR"
zip -q -r -X -FS "$OUTPUT" manifest.json popup.html popup.css src/core.js src/popup.js icons/icon-16.png icons/icon-48.png icons/icon-128.png
printf 'Created %s\n' "$OUTPUT"
58 changes: 58 additions & 0 deletions repostexchange-companion/scripts/render_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Create the extension's small PNG icons without external dependencies."""

from pathlib import Path
import struct
import zlib


ROOT = Path(__file__).resolve().parents[1]
ICON_DIR = ROOT / "icons"
MASTER_SIZE = 512


def chunk(tag: bytes, data: bytes) -> bytes:
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)


def in_round_rect(x: float, y: float, left: float, top: float, right: float, bottom: float, radius: float) -> bool:
cx = min(max(x, left + radius), right - radius)
cy = min(max(y, top + radius), bottom - radius)
return (x - cx) ** 2 + (y - cy) ** 2 <= radius**2


def pixel_color(x: float, y: float) -> tuple[int, int, int, int]:
inset = 8
radius = 105
if in_round_rect(x, y, inset, inset, MASTER_SIZE - inset, MASTER_SIZE - inset, radius):
color = (21, 95, 75, 255)
else:
color = (0, 0, 0, 0)

bar_centers = [171, 214, 256, 298, 341]
bar_heights = [78, 144, 202, 144, 78]
width = 24
bar_color = (198, 242, 121, 255)
for center, height in zip(bar_centers, bar_heights):
top = (MASTER_SIZE - height) / 2
bottom = top + height
if in_round_rect(x, y, center - width / 2, top, center + width / 2, bottom, width / 2):
color = bar_color
return color


def write_png(path: Path) -> None:
rows = bytearray()
for y in range(MASTER_SIZE):
rows.append(0)
for x in range(MASTER_SIZE):
rows.extend(pixel_color(x + 0.5, y + 0.5))
header = struct.pack(">IIBBBBB", MASTER_SIZE, MASTER_SIZE, 8, 6, 0, 0, 0)
data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b"")
path.write_bytes(data)


if __name__ == "__main__":
ICON_DIR.mkdir(parents=True, exist_ok=True)
write_png(ICON_DIR / "icon-master.png")
57 changes: 57 additions & 0 deletions repostexchange-companion/src/core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
(function attachCore(root, build) {
const api = build();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.RepostCompanionCore = api;
})(typeof globalThis === "object" ? globalThis : this, function buildCore() {
const DAILY_TARGET = 20;
const ROLLING_LIMIT = 10;
const WINDOW_MS = 12 * 60 * 60 * 1000;

function localDateKey(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

function parseCompletion(entry) {
const value = typeof entry === "number" ? entry : entry && entry.completedAt;
const timestamp = typeof value === "number" ? value : Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}

function getProgress(entries, now = Date.now()) {
const list = Array.isArray(entries) ? entries : [];
const todayKey = localDateKey(now);
const completed = list
.map((entry) => ({ timestamp: parseCompletion(entry) }))
.filter(({ timestamp }) => timestamp !== null && timestamp <= now);
const todayCount = completed.filter(({ timestamp }) => localDateKey(timestamp) === todayKey).length;
const recent = completed
.filter(({ timestamp }) => now - timestamp < WINDOW_MS)
.sort((a, b) => a.timestamp - b.timestamp);
const nextSlotAt = recent.length >= ROLLING_LIMIT
? recent[recent.length - ROLLING_LIMIT].timestamp + WINDOW_MS
: null;

return {
todayCount,
dailyTarget: DAILY_TARGET,
rollingCount: recent.length,
rollingLimit: ROLLING_LIMIT,
nextSlotAt,
canLog: todayCount < DAILY_TARGET && recent.length < ROLLING_LIMIT
};
}

return Object.freeze({
DAILY_TARGET,
ROLLING_LIMIT,
WINDOW_MS,
getProgress,
localDateKey,
parseCompletion
});
});
Loading
Loading