catime 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
catime/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """catime - AI-generated hourly cat images."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
catime/cli.py CHANGED
@@ -64,14 +64,48 @@ def filter_by_query(cats: list[dict], query: str) -> list[dict]:
64
64
  return []
65
65
 
66
66
 
67
+ def cmd_view(args):
68
+ """Serve the cat gallery locally in a browser."""
69
+ import http.server
70
+ import functools
71
+ import threading
72
+ import webbrowser
73
+
74
+ docs_dir = Path(__file__).resolve().parent / "docs"
75
+ if not docs_dir.exists():
76
+ # Fallback: project root docs/
77
+ docs_dir = Path(__file__).resolve().parent.parent.parent / "docs"
78
+ if not docs_dir.exists():
79
+ print("Error: docs/ directory not found.", file=sys.stderr)
80
+ sys.exit(1)
81
+
82
+ port = args.port
83
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(docs_dir))
84
+ server = http.server.HTTPServer(("127.0.0.1", port), handler)
85
+ url = f"http://127.0.0.1:{port}"
86
+ print(f"Serving cat gallery at {url}")
87
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
88
+ try:
89
+ server.serve_forever()
90
+ except KeyboardInterrupt:
91
+ print("\nStopped.")
92
+
93
+
67
94
  def main():
95
+ # Handle 'view' subcommand separately to avoid argparse conflicts
96
+ if len(sys.argv) >= 2 and sys.argv[1] == "view":
97
+ view_parser = argparse.ArgumentParser(prog="catime view")
98
+ view_parser.add_argument("--port", type=int, default=8000, help="Port (default: 8000)")
99
+ cmd_view(view_parser.parse_args(sys.argv[2:]))
100
+ return
101
+
68
102
  parser = argparse.ArgumentParser(
69
103
  prog="catime",
70
104
  description="View AI-generated hourly cat images",
71
105
  )
72
106
  parser.add_argument(
73
107
  "query", nargs="?",
74
- help="Cat number (e.g. 42), date (2026-01-30), date+hour (2026-01-30T05), 'today', or 'yesterday'.",
108
+ help="Cat number (e.g. 42), date (2026-01-30), date+hour (2026-01-30T05), 'today', 'yesterday', or 'view'.",
75
109
  )
76
110
  parser.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo owner/name")
77
111
  parser.add_argument("--local", action="store_true", help="Use local catlist.json")
@@ -103,7 +137,14 @@ def main():
103
137
  print(" catime yesterday List yesterday's cats")
104
138
  print(" catime 2026-01-30 List all cats from a date")
105
139
  print(" catime 2026-01-30T05 View the cat from a specific hour")
140
+ print(" catime latest View the latest cat")
106
141
  print(" catime --list List all cats")
142
+ print(" catime view Open cat gallery in browser")
143
+ return
144
+
145
+ # latest
146
+ if args.query == "latest":
147
+ print_cat(cats[-1], len(cats))
107
148
  return
108
149
 
109
150
  # Try as number first
catime/docs/app.js ADDED
@@ -0,0 +1,223 @@
1
+ (function () {
2
+ const CATLIST_URL = "https://raw.githubusercontent.com/yazelin/catime/main/catlist.json";
3
+ const PAGE_SIZE = 20;
4
+
5
+ let allCats = [];
6
+ let filtered = [];
7
+ let loaded = 0;
8
+ let loading = false;
9
+ let selectedDate = ""; // "YYYY-MM-DD" or ""
10
+
11
+ const gallery = document.getElementById("gallery");
12
+ const endMsg = document.getElementById("end-msg");
13
+ const modelSelect = document.getElementById("model-filter");
14
+ const timelineList = document.getElementById("timeline-list");
15
+ const timelineNav = document.getElementById("timeline");
16
+ const timelineToggle = document.getElementById("timeline-toggle");
17
+ const lightbox = document.getElementById("lightbox");
18
+ const lbImg = document.getElementById("lb-img");
19
+ const lbInfo = document.getElementById("lb-info");
20
+ const lbClose = document.getElementById("lb-close");
21
+
22
+ // Date picker elements
23
+ const datePickerBtn = document.getElementById("date-picker-btn");
24
+ const dateDropdown = document.getElementById("date-dropdown");
25
+ const ddPrev = document.getElementById("dd-prev");
26
+ const ddNext = document.getElementById("dd-next");
27
+ const ddMonthLabel = document.getElementById("dd-month-label");
28
+ const ddDays = document.getElementById("dd-days");
29
+ const ddClear = document.getElementById("dd-clear");
30
+
31
+ let calYear = new Date().getFullYear();
32
+ let calMonth = new Date().getMonth();
33
+ let catDates = new Set();
34
+
35
+ // Fetch data
36
+ fetch(CATLIST_URL)
37
+ .then(r => r.json())
38
+ .then(data => {
39
+ allCats = data.filter(c => c.status !== "failed").reverse();
40
+ allCats.forEach(c => catDates.add(c.timestamp.split(" ")[0]));
41
+ populateModels();
42
+ buildTimeline();
43
+ // Init calendar to latest cat's month
44
+ if (allCats.length) {
45
+ const parts = allCats[0].timestamp.split(" ")[0].split("-");
46
+ calYear = parseInt(parts[0], 10);
47
+ calMonth = parseInt(parts[1], 10) - 1;
48
+ }
49
+ applyFilter();
50
+ })
51
+ .catch(err => {
52
+ gallery.innerHTML = `<p style="padding:2rem;color:var(--pink)">Failed to load cat list: ${err.message}</p>`;
53
+ });
54
+
55
+ function populateModels() {
56
+ const models = [...new Set(allCats.map(c => c.model).filter(Boolean))].sort();
57
+ models.forEach(m => {
58
+ const opt = document.createElement("option");
59
+ opt.value = m; opt.textContent = m;
60
+ modelSelect.appendChild(opt);
61
+ });
62
+ }
63
+
64
+ function buildTimeline() {
65
+ const map = {};
66
+ allCats.forEach(c => {
67
+ const [date] = c.timestamp.split(" ");
68
+ const [y, m] = date.split("-");
69
+ if (!map[y]) map[y] = new Set();
70
+ map[y].add(m);
71
+ });
72
+ let html = "";
73
+ Object.keys(map).sort().reverse().forEach(y => {
74
+ html += `<div class="year">${y}</div>`;
75
+ [...map[y]].sort().reverse().forEach(m => {
76
+ html += `<a href="#" data-ym="${y}-${m}">${y}-${m}</a>`;
77
+ });
78
+ });
79
+ timelineList.innerHTML = html;
80
+ }
81
+
82
+ // ── Date picker ──
83
+ datePickerBtn.addEventListener("click", e => {
84
+ e.stopPropagation();
85
+ dateDropdown.classList.toggle("hidden");
86
+ if (!dateDropdown.classList.contains("hidden")) renderCalendar();
87
+ });
88
+ document.addEventListener("click", e => {
89
+ if (!dateDropdown.classList.contains("hidden") && !document.getElementById("date-picker").contains(e.target)) {
90
+ dateDropdown.classList.add("hidden");
91
+ }
92
+ });
93
+ ddPrev.addEventListener("click", () => { calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } renderCalendar(); });
94
+ ddNext.addEventListener("click", () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } renderCalendar(); });
95
+ ddClear.addEventListener("click", () => {
96
+ selectedDate = "";
97
+ datePickerBtn.textContent = "\u{1f4c5} All Dates";
98
+ datePickerBtn.classList.remove("active");
99
+ dateDropdown.classList.add("hidden");
100
+ applyFilter();
101
+ });
102
+
103
+ function renderCalendar() {
104
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
105
+ ddMonthLabel.textContent = `${months[calMonth]} ${calYear}`;
106
+ const first = new Date(calYear, calMonth, 1);
107
+ const startDay = first.getDay();
108
+ const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
109
+ const todayStr = new Date().toISOString().slice(0, 10);
110
+
111
+ let html = "";
112
+ // Empty cells before first day
113
+ for (let i = 0; i < startDay; i++) html += `<button class="other-month" disabled></button>`;
114
+ for (let d = 1; d <= daysInMonth; d++) {
115
+ const ds = `${calYear}-${String(calMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
116
+ const cls = [];
117
+ if (ds === todayStr) cls.push("today");
118
+ if (ds === selectedDate) cls.push("selected");
119
+ if (catDates.has(ds)) cls.push("has-cat");
120
+ html += `<button data-date="${ds}" class="${cls.join(" ")}">${d}</button>`;
121
+ }
122
+ ddDays.innerHTML = html;
123
+ }
124
+
125
+ ddDays.addEventListener("click", e => {
126
+ const date = e.target.dataset.date;
127
+ if (!date) return;
128
+ selectedDate = date;
129
+ datePickerBtn.textContent = "\u{1f4c5} " + date;
130
+ datePickerBtn.classList.add("active");
131
+ dateDropdown.classList.add("hidden");
132
+ applyFilter();
133
+ });
134
+
135
+ // ── Filter ──
136
+ function applyFilter() {
137
+ const model = modelSelect.value;
138
+ filtered = allCats.filter(c => {
139
+ if (model && c.model !== model) return false;
140
+ if (selectedDate && !c.timestamp.startsWith(selectedDate)) return false;
141
+ return true;
142
+ });
143
+ loaded = 0;
144
+ gallery.innerHTML = "";
145
+ endMsg.classList.add("loading");
146
+ endMsg.classList.remove("hidden");
147
+ loadMore();
148
+ }
149
+
150
+ modelSelect.addEventListener("change", applyFilter);
151
+
152
+ // ── Render cards ──
153
+ function loadMore() {
154
+ if (loading || loaded >= filtered.length) return;
155
+ loading = true;
156
+ const slice = filtered.slice(loaded, loaded + PAGE_SIZE);
157
+ let lastMonth = "";
158
+ if (loaded > 0) {
159
+ const prev = filtered[loaded - 1];
160
+ lastMonth = prev.timestamp.slice(0, 7);
161
+ }
162
+ const frag = document.createDocumentFragment();
163
+ slice.forEach(cat => {
164
+ const month = cat.timestamp.slice(0, 7);
165
+ if (month !== lastMonth) {
166
+ const sep = document.createElement("div");
167
+ sep.className = "month-sep";
168
+ sep.id = `m-${month}`;
169
+ sep.textContent = month;
170
+ frag.appendChild(sep);
171
+ lastMonth = month;
172
+ }
173
+ const card = document.createElement("div");
174
+ card.className = "card";
175
+ card.innerHTML = `
176
+ <img src="${cat.url}" alt="Cat #${cat.number}" loading="lazy">
177
+ <div class="card-info">
178
+ <div class="time">#${cat.number} &middot; ${cat.timestamp}</div>
179
+ ${cat.model ? `<span class="model">${cat.model}</span>` : ""}
180
+ </div>`;
181
+ card.addEventListener("click", () => openLightbox(cat));
182
+ frag.appendChild(card);
183
+ });
184
+ gallery.appendChild(frag);
185
+ loaded += slice.length;
186
+ loading = false;
187
+ if (loaded >= filtered.length) {
188
+ endMsg.classList.remove("loading");
189
+ }
190
+ }
191
+
192
+ // ── Infinite scroll ──
193
+ const observer = new IntersectionObserver(entries => {
194
+ if (entries[0].isIntersecting) loadMore();
195
+ }, { rootMargin: "400px" });
196
+ observer.observe(endMsg);
197
+
198
+ // ── Timeline click ──
199
+ timelineList.addEventListener("click", e => {
200
+ e.preventDefault();
201
+ const ym = e.target.dataset.ym;
202
+ if (!ym) return;
203
+ const idx = filtered.findIndex(c => c.timestamp.startsWith(ym));
204
+ if (idx === -1) return;
205
+ while (loaded <= idx && loaded < filtered.length) loadMore();
206
+ const el = document.getElementById(`m-${ym}`);
207
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
208
+ if (window.innerWidth <= 1024) timelineNav.classList.remove("open");
209
+ });
210
+
211
+ timelineToggle.addEventListener("click", () => timelineNav.classList.toggle("open"));
212
+
213
+ // ── Lightbox ──
214
+ function openLightbox(cat) {
215
+ lbImg.src = cat.url;
216
+ lbInfo.textContent = `#${cat.number} \u00b7 ${cat.timestamp} \u00b7 ${cat.model || ""}`;
217
+ lightbox.classList.remove("hidden");
218
+ }
219
+ lbClose.addEventListener("click", () => lightbox.classList.add("hidden"));
220
+ lightbox.addEventListener("click", e => { if (e.target === lightbox) lightbox.classList.add("hidden"); });
221
+ document.addEventListener("keydown", e => { if (e.key === "Escape") lightbox.classList.add("hidden"); });
222
+
223
+ })();
Binary file
Binary file
Binary file
Binary file
catime/docs/index.html ADDED
@@ -0,0 +1,58 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Catime - AI Cat Gallery</title>
7
+ <link rel="icon" href="favicon.ico" sizes="any">
8
+ <link rel="icon" href="favicon-32.png" type="image/png" sizes="32x32">
9
+ <link rel="icon" href="icon-192.png" type="image/png" sizes="192x192">
10
+ <link rel="apple-touch-icon" href="apple-touch-icon.png">
11
+ <link rel="stylesheet" href="style.css">
12
+ </head>
13
+ <body>
14
+ <header id="topbar">
15
+ <img src="icon-192.png" alt="Catime" class="logo-icon">
16
+ <h1>Catime</h1>
17
+ <div class="filters">
18
+ <select id="model-filter">
19
+ <option value="">All Models</option>
20
+ </select>
21
+ <div class="date-picker" id="date-picker">
22
+ <button class="date-picker-btn" id="date-picker-btn">&#x1f4c5; All Dates</button>
23
+ <div class="date-dropdown hidden" id="date-dropdown">
24
+ <div class="dd-header">
25
+ <button class="dd-nav" id="dd-prev">&lsaquo;</button>
26
+ <span id="dd-month-label"></span>
27
+ <button class="dd-nav" id="dd-next">&rsaquo;</button>
28
+ </div>
29
+ <div class="dd-weekdays">
30
+ <span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
31
+ </div>
32
+ <div class="dd-days" id="dd-days"></div>
33
+ <button class="dd-clear" id="dd-clear">Clear</button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ <a href="https://github.com/yazelin/catime" target="_blank" rel="noopener" class="github-link" title="View on GitHub">
38
+ <svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
39
+ </a>
40
+ </header>
41
+
42
+ <nav id="timeline" aria-label="Timeline navigation">
43
+ <button id="timeline-toggle" aria-label="Toggle timeline">&#x1f4c5;</button>
44
+ <div id="timeline-list"></div>
45
+ </nav>
46
+
47
+ <main id="gallery" class="masonry"></main>
48
+ <div id="end-msg" class="hidden">No more cats! 🐱</div>
49
+
50
+ <div id="lightbox" class="hidden" role="dialog">
51
+ <button id="lb-close" aria-label="Close">&times;</button>
52
+ <img id="lb-img" src="" alt="Cat">
53
+ <div id="lb-info"></div>
54
+ </div>
55
+
56
+ <script src="app.js"></script>
57
+ </body>
58
+ </html>
catime/docs/style.css ADDED
@@ -0,0 +1,294 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap');
2
+
3
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4
+
5
+ :root {
6
+ --pink: #ff6b9d;
7
+ --pink-light: #ffa5c8;
8
+ --pink-pale: #fff0f5;
9
+ --orange: #ffb347;
10
+ --red: #ff6b6b;
11
+ --blue: #74b9ff;
12
+ --green: #a8e6cf;
13
+ --purple: #c9b1ff;
14
+ --bg: #fff5f9;
15
+ --surface: #ffffff;
16
+ --card-1: #fff0f5;
17
+ --card-2: #f0f8ff;
18
+ --card-3: #f5fff0;
19
+ --card-4: #fef5ff;
20
+ --card-5: #fff8f0;
21
+ --text: #5a4a5a;
22
+ --text-muted: #b8a0b8;
23
+ --shadow: rgba(255, 107, 157, .15);
24
+ }
25
+
26
+ body {
27
+ font-family: 'Nunito', system-ui, sans-serif;
28
+ background: var(--bg);
29
+ background-image:
30
+ radial-gradient(circle at 10% 20%, rgba(255,107,157,.06) 0%, transparent 50%),
31
+ radial-gradient(circle at 90% 80%, rgba(116,185,255,.06) 0%, transparent 50%),
32
+ radial-gradient(circle at 50% 50%, rgba(201,177,255,.05) 0%, transparent 50%);
33
+ color: var(--text);
34
+ min-height: 100vh;
35
+ }
36
+
37
+ /* Top bar */
38
+ #topbar {
39
+ position: fixed; top: 0; left: 0; right: 0; z-index: 100;
40
+ display: flex; align-items: center; gap: .6rem;
41
+ padding: .6rem 1rem;
42
+ background: linear-gradient(135deg, #fff 0%, var(--pink-pale) 100%);
43
+ border-bottom: 3px solid var(--pink-light);
44
+ box-shadow: 0 2px 16px var(--shadow);
45
+ flex-wrap: wrap;
46
+ }
47
+ .logo-icon {
48
+ width: 34px; height: 34px; border-radius: 50%;
49
+ flex-shrink: 0;
50
+ }
51
+ #topbar h1 {
52
+ font-size: 1.3rem; font-weight: 800; white-space: nowrap;
53
+ background: linear-gradient(135deg, #9b7ec8, var(--pink), #ffb347);
54
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
55
+ background-clip: text;
56
+ }
57
+ .filters {
58
+ display: flex; gap: .5rem; flex: 1; justify-content: flex-end;
59
+ align-items: center; flex-wrap: wrap; min-width: 0;
60
+ }
61
+ .filters select {
62
+ padding: .45rem .8rem; border-radius: 20px;
63
+ border: 2px solid var(--pink-light);
64
+ background: #fff; color: var(--text); font-size: .85rem;
65
+ font-family: inherit; cursor: pointer;
66
+ transition: border-color .2s, box-shadow .2s;
67
+ min-width: 0;
68
+ }
69
+ .filters select:focus {
70
+ outline: none; border-color: var(--pink);
71
+ box-shadow: 0 0 0 3px rgba(255,107,157,.2);
72
+ }
73
+
74
+ /* Custom date picker */
75
+ .date-picker { position: relative; }
76
+ .date-picker-btn {
77
+ padding: .45rem .8rem; border-radius: 20px;
78
+ border: 2px solid var(--pink-light);
79
+ background: #fff; color: var(--text); font-size: .85rem;
80
+ font-family: inherit; cursor: pointer; white-space: nowrap;
81
+ transition: border-color .2s, box-shadow .2s;
82
+ }
83
+ .date-picker-btn:hover { border-color: var(--pink); }
84
+ .date-picker-btn.active {
85
+ border-color: var(--pink); background: var(--pink-pale);
86
+ }
87
+ .date-dropdown {
88
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 150;
89
+ background: #fff; border-radius: 16px; padding: .8rem;
90
+ border: 2px solid var(--pink-light);
91
+ box-shadow: 0 8px 30px var(--shadow);
92
+ width: 260px;
93
+ }
94
+ .dd-header {
95
+ display: flex; align-items: center; justify-content: space-between;
96
+ margin-bottom: .5rem;
97
+ }
98
+ .dd-header span {
99
+ font-weight: 800; font-size: .9rem;
100
+ background: linear-gradient(135deg, var(--pink), var(--purple));
101
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
102
+ background-clip: text;
103
+ }
104
+ .dd-nav {
105
+ width: 30px; height: 30px; border-radius: 50%; border: none;
106
+ background: var(--pink-pale); color: var(--pink);
107
+ font-size: 1.1rem; font-weight: 800; cursor: pointer;
108
+ transition: background .2s;
109
+ }
110
+ .dd-nav:hover { background: var(--pink-light); color: #fff; }
111
+ .dd-weekdays {
112
+ display: grid; grid-template-columns: repeat(7, 1fr);
113
+ text-align: center; font-size: .7rem; font-weight: 700;
114
+ color: var(--text-muted); margin-bottom: .3rem;
115
+ }
116
+ .dd-days {
117
+ display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px;
118
+ }
119
+ .dd-days button {
120
+ aspect-ratio: 1; border: none; border-radius: 50%;
121
+ background: transparent; color: var(--text); font-size: .78rem;
122
+ font-family: inherit; font-weight: 600; cursor: pointer;
123
+ transition: all .15s;
124
+ }
125
+ .dd-days button:hover { background: var(--pink-pale); }
126
+ .dd-days button.today { border: 2px solid var(--pink-light); }
127
+ .dd-days button.selected {
128
+ background: linear-gradient(135deg, var(--pink), var(--purple));
129
+ color: #fff;
130
+ }
131
+ .dd-days button.has-cat::after {
132
+ content: ""; display: block; width: 4px; height: 4px;
133
+ background: var(--pink); border-radius: 50%;
134
+ margin: -2px auto 0;
135
+ }
136
+ .dd-days button.selected.has-cat::after { background: #fff; }
137
+ .dd-days button.other-month { color: var(--text-muted); opacity: .4; }
138
+ .dd-clear {
139
+ display: block; width: 100%; margin-top: .5rem; padding: .4rem;
140
+ border: none; border-radius: 20px;
141
+ background: var(--pink-pale); color: var(--pink);
142
+ font-family: inherit; font-size: .8rem; font-weight: 700;
143
+ cursor: pointer; transition: background .2s;
144
+ }
145
+ .dd-clear:hover { background: var(--pink-light); color: #fff; }
146
+
147
+ /* GitHub link */
148
+ .github-link {
149
+ color: var(--pink); flex-shrink: 0;
150
+ transition: color .2s, transform .2s;
151
+ display: flex; align-items: center;
152
+ }
153
+ .github-link:hover { color: var(--purple); transform: scale(1.15) rotate(-8deg); }
154
+
155
+ /* Timeline sidebar */
156
+ #timeline {
157
+ position: fixed; top: 56px; right: 0; bottom: 0; width: 130px; z-index: 90;
158
+ background: linear-gradient(180deg, #fff 0%, var(--pink-pale) 100%);
159
+ border-left: 2px solid var(--pink-light);
160
+ overflow-y: auto; padding: .8rem 0;
161
+ transition: transform .3s ease;
162
+ }
163
+ #timeline::-webkit-scrollbar { width: 4px; }
164
+ #timeline::-webkit-scrollbar-thumb { background: var(--pink-light); border-radius: 4px; }
165
+
166
+ #timeline-toggle {
167
+ display: none; position: fixed; bottom: 1.2rem; right: 1.2rem; z-index: 91;
168
+ width: 48px; height: 48px; border-radius: 50%; border: none;
169
+ background: linear-gradient(135deg, var(--pink), var(--purple));
170
+ color: #fff; font-size: 1.3rem; cursor: pointer;
171
+ box-shadow: 0 4px 15px rgba(255,107,157,.35);
172
+ transition: transform .2s;
173
+ }
174
+ #timeline-toggle:hover { transform: scale(1.1); }
175
+
176
+ #timeline-list a {
177
+ display: block; padding: .3rem .8rem; color: var(--text-muted);
178
+ text-decoration: none; font-size: .8rem; font-weight: 600;
179
+ border-right: 3px solid transparent;
180
+ transition: all .2s;
181
+ }
182
+ #timeline-list a:hover {
183
+ color: var(--pink); background: rgba(255,107,157,.08);
184
+ border-right-color: var(--pink);
185
+ }
186
+ #timeline-list .year {
187
+ font-weight: 800; color: var(--purple); margin-top: .6rem;
188
+ padding: .2rem .8rem; font-size: .85rem;
189
+ }
190
+
191
+ /* Gallery masonry */
192
+ .masonry {
193
+ margin-top: 60px; margin-right: 130px; padding: 1.2rem;
194
+ column-count: 3; column-gap: 1.2rem;
195
+ }
196
+
197
+ /* Candy color rotation for cards */
198
+ .card {
199
+ break-inside: avoid; margin-bottom: 1.2rem;
200
+ border-radius: 16px; overflow: hidden;
201
+ cursor: pointer;
202
+ transition: transform .2s ease, box-shadow .2s ease;
203
+ border: 2px solid transparent;
204
+ }
205
+ .card:nth-child(5n+1) { background: var(--card-1); border-color: rgba(255,107,157,.2); }
206
+ .card:nth-child(5n+2) { background: var(--card-2); border-color: rgba(116,185,255,.2); }
207
+ .card:nth-child(5n+3) { background: var(--card-3); border-color: rgba(168,230,207,.25); }
208
+ .card:nth-child(5n+4) { background: var(--card-4); border-color: rgba(201,177,255,.25); }
209
+ .card:nth-child(5n+5) { background: var(--card-5); border-color: rgba(255,179,71,.2); }
210
+
211
+ .card:hover {
212
+ transform: translateY(-4px) scale(1.01);
213
+ box-shadow: 0 8px 25px var(--shadow);
214
+ }
215
+ .card img { width: 100%; display: block; border-radius: 14px 14px 0 0; }
216
+ .card-info { padding: .6rem .8rem; font-size: .78rem; }
217
+ .card-info .time { color: var(--text-muted); font-weight: 600; }
218
+ .card-info .model {
219
+ display: inline-block; margin-top: .3rem; padding: .15rem .55rem;
220
+ border-radius: 20px; font-size: .7rem; font-weight: 700;
221
+ color: #fff;
222
+ }
223
+ /* Rotate model tag colors */
224
+ .card:nth-child(5n+1) .model { background: linear-gradient(135deg, var(--pink), var(--red)); }
225
+ .card:nth-child(5n+2) .model { background: linear-gradient(135deg, var(--blue), var(--purple)); }
226
+ .card:nth-child(5n+3) .model { background: linear-gradient(135deg, var(--green), #6bcfa8); }
227
+ .card:nth-child(5n+4) .model { background: linear-gradient(135deg, var(--purple), var(--pink)); }
228
+ .card:nth-child(5n+5) .model { background: linear-gradient(135deg, var(--orange), var(--red)); }
229
+
230
+ /* Month separator */
231
+ .month-sep {
232
+ column-span: all; padding: .5rem 0; font-size: .95rem;
233
+ font-weight: 800; letter-spacing: .02em;
234
+ background: linear-gradient(135deg, var(--pink), var(--purple));
235
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
236
+ background-clip: text;
237
+ border-bottom: 2px dashed var(--pink-light);
238
+ margin-bottom: .8rem;
239
+ }
240
+ .month-sep::before { content: "🌸 "; -webkit-text-fill-color: initial; }
241
+
242
+ /* End / scroll sentinel */
243
+ #end-msg {
244
+ text-align: center; padding: 2.5rem; font-size: 1rem;
245
+ color: var(--text-muted); font-weight: 700;
246
+ margin-right: 130px;
247
+ }
248
+ #end-msg.loading { visibility: hidden; height: 1px; padding: 0; }
249
+ .hidden { display: none !important; }
250
+
251
+ /* Lightbox */
252
+ #lightbox {
253
+ position: fixed; inset: 0; z-index: 200;
254
+ background: rgba(90, 74, 90, .8);
255
+ backdrop-filter: blur(8px);
256
+ display: flex; flex-direction: column;
257
+ align-items: center; justify-content: center;
258
+ }
259
+ #lb-close {
260
+ position: absolute; top: 1rem; right: 1.2rem;
261
+ background: none; border: none; color: #fff; font-size: 2.2rem; cursor: pointer;
262
+ text-shadow: 0 2px 8px rgba(0,0,0,.3);
263
+ transition: transform .2s;
264
+ }
265
+ #lb-close:hover { transform: scale(1.2) rotate(90deg); }
266
+ #lb-img {
267
+ max-width: 90vw; max-height: 80vh;
268
+ border-radius: 16px;
269
+ box-shadow: 0 12px 40px rgba(0,0,0,.3);
270
+ }
271
+ #lb-info {
272
+ margin-top: .8rem; color: #fff; font-size: .9rem;
273
+ font-weight: 600; text-shadow: 0 1px 4px rgba(0,0,0,.3);
274
+ }
275
+
276
+ /* Responsive */
277
+ @media (max-width: 1024px) {
278
+ .masonry { column-count: 2; margin-right: 0; }
279
+ #end-msg { margin-right: 0; }
280
+ #timeline { transform: translateX(100%); }
281
+ #timeline.open { transform: translateX(0); }
282
+ #timeline-toggle { display: block; }
283
+ }
284
+ @media (max-width: 600px) {
285
+ .masonry { column-count: 1; padding: .8rem; margin-top: 100px; }
286
+ #topbar { gap: .4rem; padding: .5rem .6rem; }
287
+ #topbar h1 { font-size: 1.1rem; }
288
+ .logo-icon { width: 28px; height: 28px; }
289
+ .filters { flex: 1 1 100%; order: 1; justify-content: flex-start; gap: .4rem; }
290
+ .filters select { font-size: .78rem; padding: .35rem .5rem; }
291
+ .date-picker-btn { font-size: .78rem; padding: .35rem .5rem; }
292
+ .date-dropdown { width: calc(100vw - 1.2rem); max-width: 280px; right: auto; left: 0; }
293
+ .github-link { margin-left: auto; }
294
+ }
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: catime
3
+ Version: 0.4.0
4
+ Summary: AI-generated hourly cat images - a new cat every hour!
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx
9
+ Description-Content-Type: text/markdown
10
+
11
+ # <img src="docs/icon-192.png" width="32" height="32" alt="catime icon"> catime
12
+
13
+ AI-generated hourly cat images. A new cat every hour!
14
+
15
+ Every hour, a GitHub Actions workflow generates a unique cat image using [nanobanana-py](https://pypi.org/project/nanobanana-py/) (Gemini API), uploads it as a GitHub Release asset, and posts it to a monthly issue.
16
+
17
+ ## Install & Usage
18
+
19
+ ```bash
20
+ uvx catime # Show total cat count
21
+ uvx catime latest # View the latest cat
22
+ uvx catime 42 # View cat #42
23
+ uvx catime today # List today's cats
24
+ uvx catime yesterday # List yesterday's cats
25
+ uvx catime 2026-01-30 # List all cats from a date
26
+ uvx catime 2026-01-30T05 # View the cat from a specific hour
27
+ uvx catime --list # List all cats
28
+ uvx catime view # Open cat gallery in browser (localhost:8000)
29
+ uvx catime view --port 3000 # Use custom port
30
+ ```
31
+
32
+ ## How It Works
33
+
34
+ - **Image generation:** [nanobanana-py](https://pypi.org/project/nanobanana-py/) with `gemini-3-pro-image-preview` (fallback: `gemini-2.5-flash-image`)
35
+ - **Image hosting:** GitHub Release assets
36
+ - **Cat gallery:** Monthly GitHub issues (auto-created)
37
+ - **Metadata:** `catlist.json` in the repo (records timestamp, model used, success/failure)
38
+ - **Gallery:** [GitHub Pages](https://yazelin.github.io/catime) waterfall gallery (`docs/`)
39
+ - **Schedule:** GitHub Actions cron, every hour at :00
40
+
41
+ ## Setup (for your own repo)
42
+
43
+ 1. Fork or clone this repo
44
+ 2. Add `GEMINI_API_KEY` to repo Settings → Secrets
45
+ 3. The workflow will auto-create monthly issues and a `cats` release
@@ -0,0 +1,14 @@
1
+ catime/__init__.py,sha256=i8FYHW-V2UO0kNEghreL2-uQ70fLxsGQ0z5kGoNEbfk,70
2
+ catime/cli.py,sha256=9wvMNzCYkhvEJGOycuLCUf4MbvkpOAHw7Sfg0M1bN80,5743
3
+ catime/docs/app.js,sha256=PyvA4Ai7Y_ZDPbwRDAsrv8aWi1yMZV7BqUraF4uIyPE,8261
4
+ catime/docs/apple-touch-icon.png,sha256=ZSipNfat3Wz3Gu3S5hTGPfIpc5hT_eDFFgScStJLzwQ,38872
5
+ catime/docs/favicon-32.png,sha256=13byvPFWFl_u2RiFITAIVZJZk75Ljo7_N_xS6NUmX_g,2496
6
+ catime/docs/favicon.ico,sha256=2LLdNzyOh5dCYMkpcOVCMyvYyg9PVJPTmmZ2APubvpQ,8050
7
+ catime/docs/icon-192.png,sha256=hx10FySGPXgaBVWMDLR6VJd9UBOfzybOeo9fNZMZTdw,43281
8
+ catime/docs/index.html,sha256=CMKUjEIfaEGu8B7xayHfslDVqfnSmXH10l4Qn3buk44,2801
9
+ catime/docs/style.css,sha256=sS5INs7BsUvP2e5yA2x3CzFVjJ7ODQ0B1nqJRAYUEyU,10429
10
+ catime-0.4.0.dist-info/METADATA,sha256=MFjNZp8nIrt-7m-dsYVbQFmPZIBTljTpjRWDJtfYbE0,1814
11
+ catime-0.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ catime-0.4.0.dist-info/entry_points.txt,sha256=oPgi6h026vMo9YyEOH3wuNtD3A28e-s1ChJs-KWWGXw,43
13
+ catime-0.4.0.dist-info/licenses/LICENSE,sha256=p_h5YRMaNCwMqGXX5KDrk49_NWGpzAJ2d6IhKa67B0E,1064
14
+ catime-0.4.0.dist-info/RECORD,,
@@ -1,25 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: catime
3
- Version: 0.2.0
4
- Summary: AI-generated hourly cat images - a new cat every hour!
5
- License-Expression: MIT
6
- License-File: LICENSE
7
- Requires-Python: >=3.10
8
- Requires-Dist: httpx
9
- Description-Content-Type: text/markdown
10
-
11
- # catime
12
-
13
- AI-generated hourly cat images. A new cat every hour!
14
-
15
- ## Usage
16
-
17
- ```bash
18
- uvx catime # Show total cat count
19
- uvx catime 42 # View cat #42
20
- uvx catime today # List today's cats
21
- uvx catime yesterday # List yesterday's cats
22
- uvx catime 2026-01-30 # List all cats from a date
23
- uvx catime 2026-01-30T05 # View the cat from a specific hour
24
- uvx catime --list # List all cats
25
- ```
@@ -1,7 +0,0 @@
1
- catime/__init__.py,sha256=f37KsktucfUui7NyCuEJRZT_WMFOIcUFCWao5y9wsyM,70
2
- catime/cli.py,sha256=dDMYM6UvXQlDldni2rfQ70CsjUwIaQGPZj6Egcd4t8M,4243
3
- catime-0.2.0.dist-info/METADATA,sha256=bfR_tARqCSml9MpfEOnZZnBUz1bMgGF3OTPSsX88GV8,665
4
- catime-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
- catime-0.2.0.dist-info/entry_points.txt,sha256=oPgi6h026vMo9YyEOH3wuNtD3A28e-s1ChJs-KWWGXw,43
6
- catime-0.2.0.dist-info/licenses/LICENSE,sha256=p_h5YRMaNCwMqGXX5KDrk49_NWGpzAJ2d6IhKa67B0E,1064
7
- catime-0.2.0.dist-info/RECORD,,
File without changes