Browse Source

add event copy-button and kind label to all events

main
Silberengel 4 weeks ago
parent
commit
8e95778318
  1. 98
      src/lib/components/EventCopyButton.svelte
  2. 20
      src/lib/services/nostr/discussions-service.ts
  3. 228
      src/routes/repos/[npub]/[repo]/+page.svelte
  4. 4
      static/icons/copy.svg

98
src/lib/components/EventCopyButton.svelte

@ -0,0 +1,98 @@ @@ -0,0 +1,98 @@
<script lang="ts">
import { nip19 } from 'nostr-tools';
export let eventId: string;
export let kind: number | undefined = undefined;
export let pubkey: string | undefined = undefined;
// Utility function to encode event to nevent or naddr
function encodeEvent(eventId: string, kind?: number, pubkey?: string): string {
try {
// For parameterized replaceable events (kinds 30000-39999), use naddr
if (kind && kind >= 30000 && kind < 40000 && pubkey) {
// Extract identifier from event (for repo announcements, it's the repo name)
// For now, we'll use nevent for all events, but this can be extended
return nip19.neventEncode({
id: eventId,
...(pubkey ? { author: pubkey } : {})
});
}
// For all other events, use nevent
return nip19.neventEncode({
id: eventId,
...(pubkey ? { author: pubkey } : {})
});
} catch (err) {
console.error('Error encoding event:', err);
return eventId; // Fallback to raw event ID
}
}
// Copy event address to clipboard
async function copyEventAddress() {
try {
const encoded = encodeEvent(eventId, kind, pubkey);
await navigator.clipboard.writeText(encoded);
// Show a brief success message (you could use a toast library here)
alert('Copied to clipboard: ' + encoded);
} catch (err) {
console.error('Error copying to clipboard:', err);
alert('Failed to copy to clipboard');
}
}
</script>
<div class="event-copy-container">
{#if kind !== undefined}
<span class="kind-label">kind {kind}</span>
{/if}
<button
class="btn-icon"
onclick={copyEventAddress}
title="Copy event address"
>
<img src="/icons/copy.svg" alt="Copy" class="icon-inline" />
</button>
</div>
<style>
.event-copy-container {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.kind-label {
font-size: 0.75rem;
color: var(--text-secondary, #666);
opacity: 0.7;
font-weight: normal;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s;
}
.btn-icon:hover {
opacity: 1;
}
.btn-icon:active {
opacity: 0.5;
}
.btn-icon .icon-inline {
width: 16px;
height: 16px;
margin: 0;
display: block;
}
</style>

20
src/lib/services/nostr/discussions-service.ts

@ -31,21 +31,29 @@ export interface DiscussionEntry { @@ -31,21 +31,29 @@ export interface DiscussionEntry {
content: string;
author: string;
createdAt: number;
kind?: number; // Event kind (11 for threads, 1111 for comments)
pubkey?: string; // Event pubkey (for naddr encoding)
comments?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number; // Event kind (1111 for comments)
pubkey?: string; // Event pubkey
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
}>;
}>;
}>;
@ -313,6 +321,8 @@ export class DiscussionsService { @@ -313,6 +321,8 @@ export class DiscussionsService {
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
parentId?: string;
replies: any[];
}>();
@ -328,6 +338,8 @@ export class DiscussionsService { @@ -328,6 +338,8 @@ export class DiscussionsService {
content: comment.content,
author: comment.pubkey,
createdAt: comment.created_at,
kind: comment.kind,
pubkey: comment.pubkey,
parentId,
replies: []
});
@ -353,6 +365,8 @@ export class DiscussionsService { @@ -353,6 +365,8 @@ export class DiscussionsService {
content: comment.content,
author: comment.author,
createdAt: comment.createdAt,
kind: comment.kind,
pubkey: comment.pubkey,
replies: comment.replies.length > 0 ? comment.replies.map(formatComment) : undefined
};
};
@ -390,6 +404,8 @@ export class DiscussionsService { @@ -390,6 +404,8 @@ export class DiscussionsService {
content: thread.content,
author: thread.pubkey,
createdAt: thread.created_at,
kind: thread.kind,
pubkey: thread.pubkey,
comments: commentTree
});
}
@ -416,7 +432,9 @@ export class DiscussionsService { @@ -416,7 +432,9 @@ export class DiscussionsService {
id: c.id,
content: c.content,
author: c.pubkey,
createdAt: c.created_at
createdAt: c.created_at,
kind: c.kind,
pubkey: c.pubkey
}))
});
}

228
src/routes/repos/[npub]/[repo]/+page.svelte

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
import PRDetail from '$lib/components/PRDetail.svelte';
import UserBadge from '$lib/components/UserBadge.svelte';
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js';
@ -144,7 +145,7 @@ @@ -144,7 +145,7 @@
let loadingVerification = $state(false);
// Issues
let issues = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number }>>([]);
let issues = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; kind: number }>>([]);
let loadingIssues = $state(false);
let showCreateIssueDialog = $state(false);
let newIssueSubject = $state('');
@ -152,7 +153,7 @@ @@ -152,7 +153,7 @@
let newIssueLabels = $state<string[]>(['']);
// Pull Requests
let prs = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; commitId?: string }>>([]);
let prs = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; commitId?: string; kind: number }>>([]);
let loadingPRs = $state(false);
let showCreatePRDialog = $state(false);
let newPRSubject = $state('');
@ -176,8 +177,8 @@ @@ -176,8 +177,8 @@
// Thread replies
let expandedThreads = $state<Set<string>>(new Set());
let showReplyDialog = $state(false);
let replyingToThreadId = $state<string | null>(null);
let replyingToCommentId = $state<string | null>(null); // For replying to comments
let replyingToThread = $state<{ id: string; kind?: number; pubkey?: string; author: string } | null>(null);
let replyingToComment = $state<{ id: string; kind?: number; pubkey?: string; author: string } | null>(null);
let replyContent = $state('');
let creatingReply = $state(false);
@ -189,21 +190,29 @@ @@ -189,21 +190,29 @@
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
comments?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
}>;
}>;
}>
@ -725,6 +734,8 @@ @@ -725,6 +734,8 @@
content: entry.content,
author: entry.author,
createdAt: entry.createdAt,
kind: entry.kind,
pubkey: entry.pubkey,
comments: entry.comments
}));
} catch (err) {
@ -735,6 +746,7 @@ @@ -735,6 +746,7 @@
}
}
async function createDiscussionThread() {
if (!userPubkey || !userPubkeyHex) {
error = 'You must be logged in to create a discussion thread';
@ -831,7 +843,7 @@ @@ -831,7 +843,7 @@
}
}
async function createThreadReply(threadId: string | null, commentId: string | null) {
async function createThreadReply() {
if (!userPubkey || !userPubkeyHex) {
error = 'You must be logged in to reply';
return;
@ -842,7 +854,7 @@ @@ -842,7 +854,7 @@
return;
}
if (!threadId && !commentId) {
if (!replyingToThread && !replyingToComment) {
error = 'Must reply to either a thread or a comment';
return;
}
@ -899,76 +911,34 @@ @@ -899,76 +911,34 @@
let parentKind: number;
let parentPubkey: string;
if (commentId) {
// Replying to a comment - get the comment event
const commentEvents = await client.fetchEvents([
{
kinds: [KIND.COMMENT],
ids: [commentId],
limit: 1
}
]);
if (commentEvents.length === 0) {
throw new Error('Comment not found');
}
if (replyingToComment) {
// Replying to a comment - use the comment object we already have
const comment = replyingToComment;
const commentEvent = commentEvents[0];
// Find root event (E tag) or use thread ID if replying to thread comment
const ETag = commentEvent.tags.find(t => t[0] === 'E');
const KTag = commentEvent.tags.find(t => t[0] === 'K');
const PTag = commentEvent.tags.find(t => t[0] === 'P');
if (ETag && KTag) {
// Comment has root tags, use them
rootEventId = ETag[1];
rootKind = parseInt(KTag[1]);
rootPubkey = PTag?.[1] || commentEvent.pubkey;
} else if (threadId) {
// Replying to a comment in a thread, use thread as root
const threadEvents = await client.fetchEvents([
{
kinds: [KIND.THREAD],
ids: [threadId],
limit: 1
}
]);
if (threadEvents.length === 0) {
throw new Error('Thread not found');
}
rootEventId = threadId;
rootKind = KIND.THREAD;
rootPubkey = threadEvents[0].pubkey;
// Determine root: if we have a thread, use it as root; otherwise use announcement
if (replyingToThread) {
rootEventId = replyingToThread.id;
rootKind = replyingToThread.kind || KIND.THREAD;
rootPubkey = replyingToThread.pubkey || replyingToThread.author;
} else {
throw new Error('Cannot determine root event');
// Comment is directly on announcement (in "Comments" pseudo-thread)
rootEventId = announcement.id;
rootKind = KIND.REPO_ANNOUNCEMENT;
rootPubkey = announcement.pubkey;
}
// Parent is the comment we're replying to
parentEventId = commentId;
parentKind = KIND.COMMENT;
parentPubkey = commentEvent.pubkey;
} else if (threadId) {
// Replying directly to a thread
const threadEvents = await client.fetchEvents([
{
kinds: [KIND.THREAD],
ids: [threadId],
limit: 1
}
]);
if (threadEvents.length === 0) {
throw new Error('Thread not found');
}
const threadEvent = threadEvents[0];
rootEventId = threadId;
rootKind = KIND.THREAD;
rootPubkey = threadEvent.pubkey;
parentEventId = threadId;
parentKind = KIND.THREAD;
parentPubkey = threadEvent.pubkey;
parentEventId = comment.id;
parentKind = comment.kind || KIND.COMMENT;
parentPubkey = comment.pubkey || comment.author;
} else if (replyingToThread) {
// Replying directly to a thread - use the thread object we already have
rootEventId = replyingToThread.id;
rootKind = replyingToThread.kind || KIND.THREAD;
rootPubkey = replyingToThread.pubkey || replyingToThread.author;
parentEventId = replyingToThread.id;
parentKind = replyingToThread.kind || KIND.THREAD;
parentPubkey = replyingToThread.pubkey || replyingToThread.author;
} else {
throw new Error('Must specify thread or comment to reply to');
}
@ -1000,18 +970,21 @@ @@ -1000,18 +970,21 @@
throw new Error('Failed to publish reply to all relays');
}
// Save thread ID before clearing (for expanding after reload)
const threadIdToExpand = replyingToThread?.id;
// Clear form and close dialog
replyContent = '';
showReplyDialog = false;
replyingToThreadId = null;
replyingToCommentId = null;
replyingToThread = null;
replyingToComment = null;
// Reload discussions to show the new reply
await loadDiscussions();
// Expand the thread if we were replying to a thread
if (threadId) {
expandedThreads.add(threadId);
if (threadIdToExpand) {
expandedThreads.add(threadIdToExpand);
expandedThreads = new Set(expandedThreads); // Trigger reactivity
}
} catch (err) {
@ -1994,13 +1967,14 @@ @@ -1994,13 +1967,14 @@
});
if (response.ok) {
const data = await response.json();
issues = data.map((issue: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number }) => ({
issues = data.map((issue: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; kind?: number }) => ({
id: issue.id,
subject: issue.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled',
content: issue.content,
status: issue.status || 'open',
author: issue.pubkey,
created_at: issue.created_at
created_at: issue.created_at,
kind: issue.kind || KIND.ISSUE
}));
}
} catch (err) {
@ -2070,14 +2044,15 @@ @@ -2070,14 +2044,15 @@
});
if (response.ok) {
const data = await response.json();
prs = data.map((pr: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; commitId?: string }) => ({
prs = data.map((pr: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; commitId?: string; kind?: number }) => ({
id: pr.id,
subject: pr.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled',
content: pr.content,
status: pr.status || 'open',
author: pr.pubkey,
created_at: pr.created_at,
commitId: pr.tags.find((t: string[]) => t[0] === 'c')?.[1]
commitId: pr.tags.find((t: string[]) => t[0] === 'c')?.[1],
kind: pr.kind || KIND.PULL_REQUEST
}));
}
} catch (err) {
@ -2576,6 +2551,7 @@ @@ -2576,6 +2551,7 @@
<div class="issue-meta">
<span>#{issue.id.slice(0, 7)}</span>
<span>{new Date(issue.created_at * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={issue.id} kind={issue.kind} pubkey={issue.author} />
</div>
</li>
{/each}
@ -2616,6 +2592,7 @@ @@ -2616,6 +2592,7 @@
<span class="pr-commit">Commit: {pr.commitId.slice(0, 7)}</span>
{/if}
<span>{new Date(pr.created_at * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={pr.id} kind={pr.kind} pubkey={pr.author} />
</div>
</li>
{/each}
@ -2906,11 +2883,13 @@ @@ -2906,11 +2883,13 @@
<span class="discussion-type">Comments</span>
{/if}
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
{#if discussion.type === 'thread' && userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = null;
showReplyDialog = true;
}}
>
@ -2932,12 +2911,13 @@ @@ -2932,12 +2911,13 @@
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = comment.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
>
@ -2955,12 +2935,13 @@ @@ -2955,12 +2935,13 @@
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = reply.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author };
showReplyDialog = true;
}}
>
@ -2978,12 +2959,13 @@ @@ -2978,12 +2959,13 @@
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = nestedReply.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author };
showReplyDialog = true;
}}
>
@ -3013,12 +2995,13 @@ @@ -3013,12 +2995,13 @@
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = comment.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
>
@ -3036,12 +3019,13 @@ @@ -3036,12 +3019,13 @@
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = reply.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author };
showReplyDialog = true;
}}
>
@ -3059,12 +3043,13 @@ @@ -3059,12 +3043,13 @@
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = nestedReply.id;
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author };
showReplyDialog = true;
}}
>
@ -3290,7 +3275,7 @@ @@ -3290,7 +3275,7 @@
{/if}
<!-- Reply to Thread/Comment Dialog -->
{#if showReplyDialog && userPubkey && (replyingToThreadId || replyingToCommentId)}
{#if showReplyDialog && userPubkey && (replyingToThread || replyingToComment)}
<div
class="modal-overlay"
role="dialog"
@ -3298,8 +3283,8 @@ @@ -3298,8 +3283,8 @@
aria-label="Reply to thread"
onclick={() => {
showReplyDialog = false;
replyingToThreadId = null;
replyingToCommentId = null;
replyingToThread = null;
replyingToComment = null;
replyContent = '';
}}
onkeydown={(e) => e.key === 'Escape' && (showReplyDialog = false)}
@ -3313,9 +3298,9 @@ @@ -3313,9 +3298,9 @@
onclick={(e) => e.stopPropagation()}
>
<h3>
{#if replyingToCommentId}
{#if replyingToComment}
Reply to Comment
{:else if replyingToThreadId}
{:else if replyingToThread}
Reply to Thread
{:else}
Reply
@ -3329,8 +3314,8 @@ @@ -3329,8 +3314,8 @@
<button
onclick={() => {
showReplyDialog = false;
replyingToThreadId = null;
replyingToCommentId = null;
replyingToThread = null;
replyingToComment = null;
replyContent = '';
}}
class="cancel-button"
@ -3338,7 +3323,7 @@ @@ -3338,7 +3323,7 @@
Cancel
</button>
<button
onclick={() => createThreadReply(replyingToThreadId, replyingToCommentId)}
onclick={() => createThreadReply()}
disabled={!replyContent.trim() || creatingReply}
class="save-button"
>
@ -3553,11 +3538,6 @@ @@ -3553,11 +3538,6 @@
border-color: var(--accent);
}
[data-theme="dark"] .bookmark-button.bookmarked {
background: var(--accent);
color: #ffffff;
}
.bookmark-button.bookmarked:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.1);
@ -3873,21 +3853,12 @@ @@ -3873,21 +3853,12 @@
font-weight: 500;
}
[data-theme="dark"] .fork-badge {
background: var(--accent);
color: #ffffff;
}
.fork-badge a {
color: var(--accent-text, #ffffff);
text-decoration: none;
font-weight: 500;
}
[data-theme="dark"] .fork-badge a {
color: #ffffff;
}
.fork-badge a:hover {
text-decoration: underline;
opacity: 0.9;
@ -3944,11 +3915,6 @@ @@ -3944,11 +3915,6 @@
border: 1px solid transparent;
}
/* Ensure high contrast in both themes */
[data-theme="dark"] .topic-tag {
background: var(--accent);
color: #ffffff;
}
.repo-website {
margin-top: 0.5rem;
@ -4057,12 +4023,6 @@ @@ -4057,12 +4023,6 @@
border-color: #2d3748;
}
/* Dark mode adjustments for owner badge */
[data-theme="dark"] .contributor-badge.owner {
background: #718096;
color: #ffffff;
border-color: #a0aec0;
}
.contributor-badge.maintainer {
/* High contrast colors for light mode */
@ -4071,13 +4031,6 @@ @@ -4071,13 +4031,6 @@
border-color: #1a202c;
}
/* Dark mode adjustments for maintainer badge */
[data-theme="dark"] .contributor-badge.maintainer {
background: #48bb78;
color: #1a202c;
border-color: #68d391;
font-weight: 700;
}
header h1 {
margin: 0;
@ -4624,11 +4577,6 @@ @@ -4624,11 +4577,6 @@
color: var(--accent-text, #ffffff);
}
[data-theme="dark"] .commit-item.selected .commit-button {
background: var(--accent);
color: #ffffff;
}
.commit-hash {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
@ -5190,12 +5138,6 @@ @@ -5190,12 +5138,6 @@
font-weight: 600;
}
[data-theme="dark"] .issue-status.open,
[data-theme="dark"] .pr-status.open {
background: var(--accent);
color: #ffffff;
}
.issue-status.closed, .pr-status.closed {
background: var(--error-bg);
color: var(--error-text);

4
static/icons/copy.svg

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

Loading…
Cancel
Save