Attic is a cozy space with lofty ambitions. attic.social
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

bookmark tags

+999 -352
+19 -2
src/css/base/global.css
··· 92 92 } 93 93 94 94 dialog { 95 - inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin)))); 95 + inline-size: 100%; 96 + max-block-size: 100vb; 97 + margin-inline: 0; 98 + overscroll-behavior: contain; 99 + padding-block: 10px; 100 + 101 + & > form { 102 + inline-size: calc(100vi - 20px); 103 + margin-inline: auto; 104 + } 105 + 106 + @media (width >= 600px) { 107 + padding-block: 30px; 108 + 109 + & > form { 110 + inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin)))); 111 + } 112 + } 96 113 97 114 & [command="close"] { 98 115 inset-block-start: 20px; 99 - inset-inline-end: 20px; 116 + inset-inline-end: 0; 100 117 position: absolute; 101 118 z-index: 1; 102 119
+1
src/css/base/properties.css
··· 23 23 --app-margin: max(20px, min(5vi, 100px)); 24 24 25 25 --input-border: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M5 5h90v90H5z"/><path d="M0 5h5v90H0zM5 0h90v5H5zM5 95h90v5H5zM95 5h5v90h-5z"/></svg>'); 26 + --input-border-error: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M5 5h90v90H5z"/><path fill="rgb(222 34 68)" d="M0 5h5v90H0zM5 0h90v5H5zM5 95h90v5H5zM95 5h5v90h-5z"/></svg>'); 26 27 --button-border: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 27 28 --button-border-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 190 50)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 28 29 --button-border-danger: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path fill="rgb(222 34 68)" d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>');
+31 -6
src/css/components/bookmark.css
··· 36 36 box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.5); 37 37 } 38 38 39 - & > :is(h2, h3, .flex) { 40 - grid-column: 1 / -1; 41 - } 42 - 43 39 & > :is(h2, h3) { 44 40 font-size: var(--font-size-3); 41 + grid-column: 1 / -1; 42 + 43 + &:has(button) { 44 + align-items: start; 45 + grid-template-columns: 1fr auto; 46 + display: grid; 47 + gap: 10px; 48 + } 45 49 46 50 & a { 47 51 align-items: start; 48 - column-gap: 5px; 52 + column-gap: 10px; 49 53 display: flex; 50 54 51 55 &::after { ··· 85 89 86 90 & form { 87 91 display: contents; 92 + z-index: 1; 88 93 } 89 94 90 - & :is(button, form) { 95 + & button { 96 + margin-block: -10px; 97 + margin-inline-end: -10px; 91 98 z-index: 1; 99 + } 100 + 101 + & ul { 102 + display: flex; 103 + flex-wrap: wrap; 104 + gap: 5px; 105 + grid-column: 1 / -1; 106 + 107 + & li { 108 + background: rgb(var(--color-yellow)); 109 + color: rgb(var(--color-black)); 110 + font-size: var(--font-size-1); 111 + font-weight: 700; 112 + display: block; 113 + line-height: 1; 114 + min-inline-size: 0; 115 + padding: 5px; 116 + } 92 117 } 93 118 }
+16 -10
src/css/components/form.css
··· 12 12 justify-items: start; 13 13 padding: 30px; 14 14 padding-block-start: 25px; 15 + position: relative; 15 16 16 17 & label { 17 18 display: block; ··· 19 20 font-weight: 700; 20 21 inline-size: fit-content; 21 22 line-height: var(--line-height-2); 23 + 24 + & span { 25 + font-weight: 400; 26 + } 22 27 } 23 28 24 29 & > * { ··· 29 34 inline-size: 100%; 30 35 } 31 36 32 - &[action*="editBookmark"] { 33 - & input { 34 - inline-size: 100%; 35 - } 36 - } 37 - 38 - &[action*="createBookmark"] { 37 + :where(&[action*="editBookmark"]), 38 + :where(&[action*="createBookmark"]) { 39 39 & input { 40 40 inline-size: 100%; 41 41 } ··· 43 43 & div:has(input[name="title"]) { 44 44 display: grid; 45 45 inline-size: 100%; 46 - grid-template-columns: 1fr auto; 47 46 gap: 10px; 48 47 49 - & input { 50 - grid-column: 1; 48 + @media (width >= 600px) { 49 + grid-template-columns: 1fr auto; 50 + & input { 51 + grid-column: 1; 52 + } 53 + 54 + & button { 55 + grid-column: 2; 56 + } 51 57 } 52 58 } 53 59 }
+64 -1
src/css/components/input.css
··· 1 - input { 1 + input, 2 + .Tags { 2 3 border: 15px solid transparent; 3 4 border-image: var(--input-border) 15 fill stretch; 4 5 block-size: calc(var(--font-size-button) + 30px); ··· 9 10 inline-size: min(100%, 400px); 10 11 line-height: var(--line-height-1); 11 12 padding: 0; 13 + } 12 14 15 + input { 13 16 &::placeholder { 14 17 color: rgb(var(--color-black) / 0.5); 15 18 opacity: 1; ··· 18 21 &:disabled { 19 22 opacity: 0.5; 20 23 pointer-events: none; 24 + } 25 + 26 + &:user-invalid { 27 + border-image-source: var(--input-border-error); 28 + } 29 + } 30 + 31 + .Tags { 32 + block-size: auto; 33 + column-gap: 5px; 34 + display: flex; 35 + flex-wrap: wrap; 36 + inline-size: 100%; 37 + min-inline-size: 0; 38 + row-gap: 15px; 39 + 40 + &:has(:focus-visible) { 41 + outline: 4px solid magenta; 42 + outline-offset: 2px; 43 + } 44 + 45 + & :is(input) { 46 + border: 2px solid black; 47 + block-size: calc(var(--font-size-button) + 10px); 48 + field-sizing: content; 49 + inline-size: fit-content; 50 + line-height: 1; 51 + margin-block: -5px; 52 + min-inline-size: 0; 53 + overflow: hidden; 54 + padding: 5px; 55 + text-overflow: ellipsis; 56 + white-space: nowrap; 57 + 58 + &:focus-visible { 59 + outline: none; 60 + } 61 + 62 + &:not([readonly]) { 63 + border-color: transparent; 64 + min-inline-size: 5ch; 65 + flex-grow: 1; 66 + } 67 + 68 + &[readonly] { 69 + background: rgb(var(--color-yellow)); 70 + 71 + &::selection { 72 + background: transparent; 73 + } 74 + 75 + &:focus-visible { 76 + background: rgb(var(--color-light-yellow)); 77 + } 78 + 79 + &:hover { 80 + background: rgb(var(--color-light-yellow)); 81 + cursor: pointer; 82 + } 83 + } 21 84 } 22 85 }
+51
src/lib/_negotiation/common.ts
··· 1 + // Copyright 2018-2026 the Deno authors. MIT license. 2 + /*! 3 + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 + * which is licensed as follows: 5 + * 6 + * (The MIT License) 7 + * 8 + * Copyright (c) 2012-2014 Federico Romero 9 + * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 + * Copyright (c) 2014-2015 Douglas Christopher Wilson 11 + * 12 + * Permission is hereby granted, free of charge, to any person obtaining 13 + * a copy of this software and associated documentation files (the 14 + * 'Software'), to deal in the Software without restriction, including 15 + * without limitation the rights to use, copy, modify, merge, publish, 16 + * distribute, sublicense, and/or sell copies of the Software, and to 17 + * permit persons to whom the Software is furnished to do so, subject to 18 + * the following conditions: 19 + * 20 + * The above copyright notice and this permission notice shall be 21 + * included in all copies or substantial portions of the Software. 22 + * 23 + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 + */ 31 + 32 + export interface Specificity { 33 + i: number; 34 + o: number | undefined; 35 + q: number; 36 + s: number | undefined; 37 + } 38 + 39 + export function compareSpecs(a: Specificity, b: Specificity): number { 40 + return ( 41 + b.q - a.q || 42 + (b.s ?? 0) - (a.s ?? 0) || 43 + (a.o ?? 0) - (b.o ?? 0) || 44 + a.i - b.i || 45 + 0 46 + ); 47 + } 48 + 49 + export function isQuality(spec: Specificity): boolean { 50 + return spec.q > 0; 51 + }
+164
src/lib/_negotiation/encoding.ts
··· 1 + // Copyright 2018-2026 the Deno authors. MIT license. 2 + /*! 3 + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 + * which is licensed as follows: 5 + * 6 + * (The MIT License) 7 + * 8 + * Copyright (c) 2012-2014 Federico Romero 9 + * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 + * Copyright (c) 2014-2015 Douglas Christopher Wilson 11 + * 12 + * Permission is hereby granted, free of charge, to any person obtaining 13 + * a copy of this software and associated documentation files (the 14 + * 'Software'), to deal in the Software without restriction, including 15 + * without limitation the rights to use, copy, modify, merge, publish, 16 + * distribute, sublicense, and/or sell copies of the Software, and to 17 + * permit persons to whom the Software is furnished to do so, subject to 18 + * the following conditions: 19 + * 20 + * The above copyright notice and this permission notice shall be 21 + * included in all copies or substantial portions of the Software. 22 + * 23 + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 + */ 31 + 32 + import { compareSpecs, isQuality, type Specificity } from "./common.ts"; 33 + 34 + interface EncodingSpecificity extends Specificity { 35 + encoding?: string; 36 + } 37 + 38 + const simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; 39 + 40 + function parseEncoding( 41 + str: string, 42 + i: number, 43 + ): EncodingSpecificity | undefined { 44 + const match = simpleEncodingRegExp.exec(str); 45 + if (!match) { 46 + return undefined; 47 + } 48 + 49 + const encoding = match[1]!; 50 + let q = 1; 51 + if (match[2]) { 52 + const params = match[2].split(";"); 53 + for (const param of params) { 54 + const p = param.trim().split("="); 55 + if (p[0] === "q" && p[1]) { 56 + q = parseFloat(p[1]); 57 + break; 58 + } 59 + } 60 + } 61 + 62 + return { encoding, o: undefined, q, i, s: undefined }; 63 + } 64 + 65 + function specify( 66 + encoding: string, 67 + spec: EncodingSpecificity, 68 + i = -1, 69 + ): Specificity | undefined { 70 + if (!spec.encoding) { 71 + return; 72 + } 73 + let s = 0; 74 + if (spec.encoding.toLowerCase() === encoding.toLowerCase()) { 75 + s = 1; 76 + } else if (spec.encoding !== "*") { 77 + return; 78 + } 79 + 80 + return { 81 + i, 82 + o: spec.i, 83 + q: spec.q, 84 + s, 85 + }; 86 + } 87 + 88 + function parseAcceptEncoding(accept: string): EncodingSpecificity[] { 89 + const accepts = accept.split(","); 90 + const parsedAccepts: EncodingSpecificity[] = []; 91 + let hasIdentity = false; 92 + let minQuality = 1; 93 + 94 + for (const [i, accept] of accepts.entries()) { 95 + const encoding = parseEncoding(accept.trim(), i); 96 + 97 + if (encoding) { 98 + parsedAccepts.push(encoding); 99 + hasIdentity = hasIdentity || !!specify("identity", encoding); 100 + minQuality = Math.min(minQuality, encoding.q || 1); 101 + } 102 + } 103 + 104 + if (!hasIdentity) { 105 + parsedAccepts.push({ 106 + encoding: "identity", 107 + o: undefined, 108 + q: minQuality, 109 + i: accepts.length - 1, 110 + s: undefined, 111 + }); 112 + } 113 + 114 + return parsedAccepts; 115 + } 116 + 117 + function getEncodingPriority( 118 + encoding: string, 119 + accepted: Specificity[], 120 + index: number, 121 + ): Specificity { 122 + let priority: Specificity = { o: -1, q: 0, s: 0, i: 0 }; 123 + 124 + for (const s of accepted) { 125 + const spec = specify(encoding, s, index); 126 + 127 + if ( 128 + spec && 129 + (priority.s! - spec.s! || priority.q - spec.q || 130 + priority.o! - spec.o!) < 131 + 0 132 + ) { 133 + priority = spec; 134 + } 135 + } 136 + 137 + return priority; 138 + } 139 + 140 + /** Given an `Accept-Encoding` string, parse out the encoding returning a 141 + * negotiated encoding based on the `provided` encodings otherwise just a 142 + * prioritized array of encodings. */ 143 + export function preferredEncodings( 144 + accept: string, 145 + provided?: string[], 146 + ): string[] { 147 + const accepts = parseAcceptEncoding(accept); 148 + 149 + if (!provided) { 150 + return accepts 151 + .filter(isQuality) 152 + .sort(compareSpecs) 153 + .map((spec) => spec.encoding!); 154 + } 155 + 156 + const priorities = provided.map((type, index) => 157 + getEncodingPriority(type, accepts, index) 158 + ); 159 + 160 + return priorities 161 + .filter(isQuality) 162 + .sort(compareSpecs) 163 + .map((priority) => provided[priorities.indexOf(priority)]!); 164 + }
+148
src/lib/_negotiation/language.ts
··· 1 + // Copyright 2018-2026 the Deno authors. MIT license. 2 + /*! 3 + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 + * which is licensed as follows: 5 + * 6 + * (The MIT License) 7 + * 8 + * Copyright (c) 2012-2014 Federico Romero 9 + * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 + * Copyright (c) 2014-2015 Douglas Christopher Wilson 11 + * 12 + * Permission is hereby granted, free of charge, to any person obtaining 13 + * a copy of this software and associated documentation files (the 14 + * 'Software'), to deal in the Software without restriction, including 15 + * without limitation the rights to use, copy, modify, merge, publish, 16 + * distribute, sublicense, and/or sell copies of the Software, and to 17 + * permit persons to whom the Software is furnished to do so, subject to 18 + * the following conditions: 19 + * 20 + * The above copyright notice and this permission notice shall be 21 + * included in all copies or substantial portions of the Software. 22 + * 23 + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 + */ 31 + 32 + import { compareSpecs, isQuality, type Specificity } from "./common.ts"; 33 + 34 + interface LanguageSpecificity extends Specificity { 35 + prefix: string; 36 + suffix: string | undefined; 37 + full: string; 38 + } 39 + 40 + const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; 41 + 42 + function parseLanguage( 43 + str: string, 44 + i: number, 45 + ): LanguageSpecificity | undefined { 46 + const match = SIMPLE_LANGUAGE_REGEXP.exec(str); 47 + if (!match) { 48 + return undefined; 49 + } 50 + 51 + const [, prefix, suffix] = match; 52 + if (!prefix) { 53 + return undefined; 54 + } 55 + 56 + const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; 57 + 58 + let q = 1; 59 + if (match[3]) { 60 + const params = match[3].split(";"); 61 + for (const param of params) { 62 + const [key, value] = param.trim().split("="); 63 + if (key === "q" && value) { 64 + q = parseFloat(value); 65 + break; 66 + } 67 + } 68 + } 69 + 70 + return { prefix, suffix, full, i, o: undefined, q, s: undefined }; 71 + } 72 + 73 + function parseAcceptLanguage(accept: string): LanguageSpecificity[] { 74 + const accepts = accept.split(","); 75 + const result: LanguageSpecificity[] = []; 76 + 77 + for (const [i, accept] of accepts.entries()) { 78 + const language = parseLanguage(accept.trim(), i); 79 + if (language) { 80 + result.push(language); 81 + } 82 + } 83 + return result; 84 + } 85 + 86 + function specify( 87 + language: string, 88 + spec: LanguageSpecificity, 89 + i: number, 90 + ): Specificity | undefined { 91 + const p = parseLanguage(language, i); 92 + if (!p) { 93 + return undefined; 94 + } 95 + let s = 0; 96 + if (spec.full.toLowerCase() === p.full.toLowerCase()) { 97 + s |= 4; 98 + } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { 99 + s |= 2; 100 + } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { 101 + s |= 1; 102 + } else if (spec.full !== "*") { 103 + return; 104 + } 105 + 106 + return { i, o: spec.i, q: spec.q, s }; 107 + } 108 + 109 + function getLanguagePriority( 110 + language: string, 111 + accepted: LanguageSpecificity[], 112 + index: number, 113 + ): Specificity { 114 + let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; 115 + for (const accepts of accepted) { 116 + const spec = specify(language, accepts, index); 117 + if ( 118 + spec && 119 + ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || 120 + (priority.o ?? 0) - (spec.o ?? 0)) < 0 121 + ) { 122 + priority = spec; 123 + } 124 + } 125 + return priority; 126 + } 127 + 128 + export function preferredLanguages( 129 + accept = "*", 130 + provided?: string[], 131 + ): string[] { 132 + const accepts = parseAcceptLanguage(accept); 133 + 134 + if (!provided) { 135 + return accepts 136 + .filter(isQuality) 137 + .sort(compareSpecs) 138 + .map((spec) => spec.full); 139 + } 140 + 141 + const priorities = provided 142 + .map((type, index) => getLanguagePriority(type, accepts, index)); 143 + 144 + return priorities 145 + .filter(isQuality) 146 + .sort(compareSpecs) 147 + .map((priority) => provided[priorities.indexOf(priority)]!); 148 + }
+196
src/lib/_negotiation/media_type.ts
··· 1 + // Copyright 2018-2026 the Deno authors. MIT license. 2 + /*! 3 + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 + * which is licensed as follows: 5 + * 6 + * (The MIT License) 7 + * 8 + * Copyright (c) 2012-2014 Federico Romero 9 + * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 + * Copyright (c) 2014-2015 Douglas Christopher Wilson 11 + * 12 + * Permission is hereby granted, free of charge, to any person obtaining 13 + * a copy of this software and associated documentation files (the 14 + * 'Software'), to deal in the Software without restriction, including 15 + * without limitation the rights to use, copy, modify, merge, publish, 16 + * distribute, sublicense, and/or sell copies of the Software, and to 17 + * permit persons to whom the Software is furnished to do so, subject to 18 + * the following conditions: 19 + * 20 + * The above copyright notice and this permission notice shall be 21 + * included in all copies or substantial portions of the Software. 22 + * 23 + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 + */ 31 + 32 + import { compareSpecs, isQuality, type Specificity } from "./common.ts"; 33 + 34 + interface MediaTypeSpecificity extends Specificity { 35 + type: string; 36 + subtype: string; 37 + params: { [param: string]: string | undefined }; 38 + } 39 + 40 + const simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; 41 + 42 + function splitKeyValuePair(str: string): [string, string | undefined] { 43 + const [key, value] = str.split("="); 44 + return [key!.toLowerCase(), value]; 45 + } 46 + 47 + function parseMediaType( 48 + str: string, 49 + i: number, 50 + ): MediaTypeSpecificity | undefined { 51 + const match = simpleMediaTypeRegExp.exec(str); 52 + 53 + if (!match) { 54 + return; 55 + } 56 + 57 + const [, type, subtype, parameters] = match; 58 + if (!type || !subtype) { 59 + return; 60 + } 61 + 62 + const params: { [param: string]: string | undefined } = Object.create(null); 63 + let q = 1; 64 + if (parameters) { 65 + const kvps = parameters.split(";").map((p) => p.trim()).map( 66 + splitKeyValuePair, 67 + ); 68 + 69 + for (const [key, val] of kvps) { 70 + const value = val && val[0] === `"` && val[val.length - 1] === `"` 71 + ? val.slice(1, val.length - 1) 72 + : val; 73 + 74 + if (key === "q" && value) { 75 + q = parseFloat(value); 76 + break; 77 + } 78 + 79 + params[key] = value; 80 + } 81 + } 82 + 83 + return { type, subtype, params, i, o: undefined, q, s: undefined }; 84 + } 85 + 86 + function parseAccept(accept: string): MediaTypeSpecificity[] { 87 + const accepts = accept.split(",").map((p) => p.trim()); 88 + 89 + const mediaTypes: MediaTypeSpecificity[] = []; 90 + for (const [index, accept] of accepts.entries()) { 91 + const mediaType = parseMediaType(accept.trim(), index); 92 + 93 + if (mediaType) { 94 + mediaTypes.push(mediaType); 95 + } 96 + } 97 + 98 + return mediaTypes; 99 + } 100 + 101 + function getFullType(spec: MediaTypeSpecificity) { 102 + return `${spec.type}/${spec.subtype}`; 103 + } 104 + 105 + function specify( 106 + type: string, 107 + spec: MediaTypeSpecificity, 108 + index: number, 109 + ): Specificity | undefined { 110 + const p = parseMediaType(type, index); 111 + 112 + if (!p) { 113 + return; 114 + } 115 + 116 + let s = 0; 117 + 118 + if (spec.type.toLowerCase() === p.type.toLowerCase()) { 119 + s |= 4; 120 + } else if (spec.type !== "*") { 121 + return; 122 + } 123 + 124 + if (spec.subtype.toLowerCase() === p.subtype.toLowerCase()) { 125 + s |= 2; 126 + } else if (spec.subtype !== "*") { 127 + return; 128 + } 129 + 130 + const keys = Object.keys(spec.params); 131 + if (keys.length) { 132 + if ( 133 + keys.every((key) => 134 + (spec.params[key] ?? "").toLowerCase() === 135 + (p.params[key] ?? "").toLowerCase() 136 + ) 137 + ) { 138 + s |= 1; 139 + } else { 140 + return; 141 + } 142 + } 143 + 144 + return { 145 + i: index, 146 + o: spec.o, 147 + q: spec.q, 148 + s, 149 + }; 150 + } 151 + 152 + function getMediaTypePriority( 153 + type: string, 154 + accepted: MediaTypeSpecificity[], 155 + index: number, 156 + ) { 157 + let priority: Specificity = { o: -1, q: 0, s: 0, i: index }; 158 + 159 + for (const accepts of accepted) { 160 + const spec = specify(type, accepts, index); 161 + 162 + if ( 163 + spec && 164 + ((priority.s ?? 0) - (spec.s ?? 0) || 165 + (priority.q ?? 0) - (spec.q ?? 0) || 166 + (priority.o ?? 0) - (spec.o ?? 0)) < 0 167 + ) { 168 + priority = spec; 169 + } 170 + } 171 + 172 + return priority; 173 + } 174 + 175 + export function preferredMediaTypes( 176 + accept?: string | null, 177 + provided?: string[], 178 + ): string[] { 179 + const accepts = parseAccept(accept === undefined ? "*/*" : accept ?? ""); 180 + 181 + if (!provided) { 182 + return accepts 183 + .filter(isQuality) 184 + .sort(compareSpecs) 185 + .map(getFullType); 186 + } 187 + 188 + const priorities = provided.map((type, index) => { 189 + return getMediaTypePriority(type, accepts, index); 190 + }); 191 + 192 + return priorities 193 + .filter(isQuality) 194 + .sort(compareSpecs) 195 + .map((priority) => provided[priorities.indexOf(priority)]!); 196 + }
+9 -163
src/lib/negotiation.ts
··· 1 1 // Copyright 2018-2026 the Deno authors. MIT license. 2 - /*! 3 - * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 - * which is licensed as follows: 5 - * 6 - * (The MIT License) 7 - * 8 - * Copyright (c) 2012-2014 Federico Romero 9 - * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 - * Copyright (c) 2014-2015 Douglas Christopher Wilson 2 + // This module is browser compatible. 3 + 4 + /** 5 + * Contains the functions {@linkcode accepts}, {@linkcode acceptsEncodings}, and 6 + * {@linkcode acceptsLanguages} to provide content negotiation capabilities. 11 7 * 12 - * Permission is hereby granted, free of charge, to any person obtaining 13 - * a copy of this software and associated documentation files (the 14 - * 'Software'), to deal in the Software without restriction, including 15 - * without limitation the rights to use, copy, modify, merge, publish, 16 - * distribute, sublicense, and/or sell copies of the Software, and to 17 - * permit persons to whom the Software is furnished to do so, subject to 18 - * the following conditions: 19 - * 20 - * The above copyright notice and this permission notice shall be 21 - * included in all copies or substantial portions of the Software. 22 - * 23 - * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 + * @module 30 9 */ 31 10 32 - export interface Specificity { 33 - i: number; 34 - o: number | undefined; 35 - q: number; 36 - s: number | undefined; 37 - } 38 - 39 - export function compareSpecs(a: Specificity, b: Specificity): number { 40 - return ( 41 - b.q - a.q || 42 - (b.s ?? 0) - (a.s ?? 0) || 43 - (a.o ?? 0) - (b.o ?? 0) || 44 - a.i - b.i || 45 - 0 46 - ); 47 - } 48 - 49 - export function isQuality(spec: Specificity): boolean { 50 - return spec.q > 0; 51 - } 52 - 53 - interface LanguageSpecificity extends Specificity { 54 - prefix: string; 55 - suffix: string | undefined; 56 - full: string; 57 - } 58 - 59 - const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; 60 - 61 - function parseLanguage( 62 - str: string, 63 - i: number, 64 - ): LanguageSpecificity | undefined { 65 - const match = SIMPLE_LANGUAGE_REGEXP.exec(str); 66 - if (!match) { 67 - return undefined; 68 - } 69 - 70 - const [, prefix, suffix] = match; 71 - if (!prefix) { 72 - return undefined; 73 - } 74 - 75 - const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; 76 - 77 - let q = 1; 78 - if (match[3]) { 79 - const params = match[3].split(";"); 80 - for (const param of params) { 81 - const [key, value] = param.trim().split("="); 82 - if (key === "q" && value) { 83 - q = parseFloat(value); 84 - break; 85 - } 86 - } 87 - } 88 - 89 - return { prefix, suffix, full, i, o: undefined, q, s: undefined }; 90 - } 91 - 92 - function parseAcceptLanguage(accept: string): LanguageSpecificity[] { 93 - const accepts = accept.split(","); 94 - const result: LanguageSpecificity[] = []; 95 - 96 - for (const [i, accept] of accepts.entries()) { 97 - const language = parseLanguage(accept.trim(), i); 98 - if (language) { 99 - result.push(language); 100 - } 101 - } 102 - return result; 103 - } 104 - 105 - function specify( 106 - language: string, 107 - spec: LanguageSpecificity, 108 - i: number, 109 - ): Specificity | undefined { 110 - const p = parseLanguage(language, i); 111 - if (!p) { 112 - return undefined; 113 - } 114 - let s = 0; 115 - if (spec.full.toLowerCase() === p.full.toLowerCase()) { 116 - s |= 4; 117 - } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { 118 - s |= 2; 119 - } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { 120 - s |= 1; 121 - } else if (spec.full !== "*") { 122 - return; 123 - } 124 - 125 - return { i, o: spec.i, q: spec.q, s }; 126 - } 127 - 128 - function getLanguagePriority( 129 - language: string, 130 - accepted: LanguageSpecificity[], 131 - index: number, 132 - ): Specificity { 133 - let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; 134 - for (const accepts of accepted) { 135 - const spec = specify(language, accepts, index); 136 - if ( 137 - spec && 138 - ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || 139 - (priority.o ?? 0) - (spec.o ?? 0)) < 0 140 - ) { 141 - priority = spec; 142 - } 143 - } 144 - return priority; 145 - } 146 - 147 - export function preferredLanguages( 148 - accept = "*", 149 - provided?: string[], 150 - ): string[] { 151 - const accepts = parseAcceptLanguage(accept); 152 - 153 - if (!provided) { 154 - return accepts 155 - .filter(isQuality) 156 - .sort(compareSpecs) 157 - .map((spec) => spec.full); 158 - } 159 - 160 - const priorities = provided 161 - .map((type, index) => getLanguagePriority(type, accepts, index)); 162 - 163 - return priorities 164 - .filter(isQuality) 165 - .sort(compareSpecs) 166 - .map((priority) => provided[priorities.indexOf(priority)]!); 167 - } 11 + import { preferredEncodings } from "./_negotiation/encoding.ts"; 12 + import { preferredLanguages } from "./_negotiation/language.ts"; 13 + import { preferredMediaTypes } from "./_negotiation/media_type.ts"; 168 14 169 15 /** 170 16 * Returns an array of media types accepted by the request, in order of
+3 -2
src/routes/+page.svelte
··· 161 161 type="text" 162 162 id="displayName" 163 163 name="displayName" 164 - maxlength="640" 164 + maxlength="64" 165 + autocomplete="off" 165 166 value={data.user.displayName} 166 167 /> 167 168 <button type="submit">Update</button> ··· 194 195 onfocus={onFocus} 195 196 onblur={onBlur} 196 197 onkeydown={handleKeydown} 197 - autocorrect="off" 198 + autocomplete="off" 198 199 spellcheck="false" 199 200 role="combobox" 200 201 aria-autocomplete="list"
+53 -24
src/routes/bookmarks/[did=did]/+page.server.ts
··· 1 1 import { isUserEvent } from "$lib/types"; 2 - import { parseBookmark } from "$lib/valibot"; 2 + import { type BookmarkData, parseBookmark } from "$lib/valibot"; 3 3 import * as TID from "@atcute/tid"; 4 4 import { type Actions, error, fail } from "@sveltejs/kit"; 5 5 import type { PageServerLoad } from "./$types"; ··· 10 10 }; 11 11 }; 12 12 13 + const collator = new Intl.Collator("en", { 14 + numeric: true, 15 + sensitivity: "variant", 16 + }); 17 + 18 + const parseFormData = (formData: FormData) => { 19 + const data: BookmarkData = { 20 + // @ts-ignore normalize url 21 + url: URL.parse(formData.get("url"))?.href ?? "", 22 + title: String(formData.get("title") ?? ""), 23 + tags: formData.getAll("tags").filter(Boolean).map(String), 24 + createdAt: new Date().toISOString(), 25 + }; 26 + if (data.tags) { 27 + if (data.tags.length === 0) { 28 + delete data.tags; 29 + } else { 30 + data.tags.sort((a, b) => collator.compare(a, b)); 31 + } 32 + } 33 + return data; 34 + }; 35 + 13 36 export const actions = { 14 37 deleteBookmark: async (event) => { 15 38 if (isUserEvent(event) === false) { ··· 26 49 }); 27 50 if (result.ok === false) { 28 51 return fail(400, { 29 - action: "delete", 52 + action: "deleteBookmark", 30 53 error: "Failed to delete bookmark.", 31 54 }); 32 55 } 33 - return { success: true }; 56 + return { action: "deleteBookmark" }; 34 57 }, 35 58 createBookmark: async (event) => { 36 59 if (isUserEvent(event) === false) { ··· 38 61 } 39 62 const { user } = event.locals; 40 63 const formData = await event.request.formData(); 41 - formData.set("createdAt", new Date().toISOString()); 42 - const data = Object.fromEntries(formData); 64 + const data = parseFormData(formData); 43 65 try { 44 - // @ts-ignore normalize url 45 - const parsed = URL.parse(formData.get("url")); 46 - data.url = parsed?.href ?? data.url; 47 66 const record = parseBookmark(data); 48 67 const result = await user.client.post("com.atproto.repo.putRecord", { 49 68 input: { 69 + record, 70 + rkey: TID.now(), 50 71 repo: user.did, 51 72 collection: "social.attic.bookmark.entity", 52 - rkey: TID.now(), 53 - record, 54 73 }, 55 74 }); 56 75 if (result.ok === false) { 57 76 throw new Error(); 58 77 } 59 - return { success: true }; 78 + return { 79 + record, 80 + cid: result.data.cid, 81 + uri: result.data.uri, 82 + action: "createBookmark", 83 + }; 60 84 } catch (err) { 61 85 let error = "Failed to create bookmark."; 62 86 if (err instanceof Error) { 63 87 error = err.message; 64 88 } 89 + data.url = String(formData.get("url") ?? ""); 65 90 return fail(400, { 66 - data: Object.fromEntries(formData), 91 + data, 92 + error, 67 93 action: "createBookmark", 68 - error, 69 94 }); 70 95 } 71 96 }, ··· 75 100 } 76 101 const { user } = event.locals; 77 102 const formData = await event.request.formData(); 103 + const data = parseFormData(formData); 104 + const rkey = String(formData.get("rkey") ?? ""); 78 105 try { 79 106 const response = await user.client.get("com.atproto.repo.getRecord", { 80 107 params: { 108 + rkey, 81 109 repo: user.did, 82 110 collection: "social.attic.bookmark.entity", 83 - rkey: String(formData.get("rkey")), 84 111 }, 85 112 }); 86 113 if (response.ok === false) { 87 114 throw new Error(); 88 115 } 89 - const record = response.data.value; 90 - // @ts-ignore normalize url 91 - record.url = URL.parse(formData.get("url"))?.href ?? ""; 92 - record.title = formData.get("title"); 93 - parseBookmark(record); 116 + let record = parseBookmark(response.data.value); 117 + data.createdAt = record.createdAt; 118 + record = parseBookmark(data); 94 119 const result = await user.client.post("com.atproto.repo.putRecord", { 95 120 input: { 121 + rkey, 122 + record, 96 123 repo: user.did, 97 124 collection: "social.attic.bookmark.entity", 98 - rkey: String(formData.get("rkey")), 99 - record, 100 125 }, 101 126 }); 102 127 if (result.ok === false) { 103 128 throw new Error(); 104 129 } 105 - return { success: true }; 130 + return { action: "editBookmark", record }; 106 131 } catch (err) { 107 132 let error = "Failed to edit bookmark."; 108 133 if (err instanceof Error) { 109 134 error = err.message; 110 135 } 111 136 return fail(400, { 112 - data: Object.fromEntries(formData), 137 + rkey, 138 + error, 113 139 action: "editBookmark", 114 - error, 140 + data: { 141 + ...data, 142 + url: String(formData.get("url") ?? ""), 143 + }, 115 144 }); 116 145 } 117 146 },
+242 -142
src/routes/bookmarks/[did=did]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { applyAction, enhance } from "$app/forms"; 2 3 import { page } from "$app/state"; 3 4 import { closeDialog, openDialog } from "$lib/dialog.svelte.js"; 4 5 import type { BookmarkEntity, BookmarkLoad } from "$lib/types"; 5 - import { onMount } from "svelte"; 6 + import { onMount, tick } from "svelte"; 6 7 import type { Attachment } from "svelte/attachments"; 7 - import type { EventHandler } from "svelte/elements"; 8 - import type { PageProps } from "./$types"; 8 + import type { PageProps, SubmitFunction } from "./$types"; 9 + 9 10 let { data, form, params }: PageProps = $props(); 10 11 11 12 let bookmarks: BookmarkEntity[] = $derived(data.bookmarks); ··· 49 50 50 51 const isSelf = $derived(data.user && params.did === data.user.did); 51 52 52 - let editData: BookmarkEntity | null = $state(null); 53 - let editDialog: HTMLDialogElement | null = $state(null); 54 - let createDialog: HTMLDialogElement | null = $state(null); 55 - let createURL: URL | null = $state(null); 53 + let dialogAction = $state(""); 54 + let dialogEntity: BookmarkEntity | undefined = $state(undefined); 55 + let dialogRef: HTMLDialogElement | null = $state(null); 56 + let dialogFormRef: HTMLFormElement | null = $state(null); 57 + let dialogUrlInput: HTMLInputElement | null = $state(null); 58 + let dialogTitleInput: HTMLInputElement | null = $state(null); 59 + let dialogUrlValid = $state(false); 60 + let dialogTags: string[] = $state([]); 61 + let dialogTagInput: HTMLInputElement | null = $state(null); 62 + let dialogTagValue = $state(""); 63 + 64 + const setupDialog = (entity?: BookmarkEntity, resetForm = false) => { 65 + if (resetForm) { 66 + form = null; 67 + if (dialogEntity?.cid !== entity?.cid) { 68 + dialogFormRef?.reset(); 69 + } 70 + } 71 + dialogAction = entity ? "editBookmark" : (form?.action ?? "createBookmark"); 72 + dialogEntity = { 73 + uri: entity?.uri ?? "", 74 + cid: entity?.cid ?? "", 75 + createdAt: entity?.createdAt ?? "", 76 + url: form?.data?.url ?? entity?.url ?? "", 77 + title: form?.data?.title ?? entity?.title ?? "", 78 + tags: form?.data?.tags ?? entity?.tags ?? [], 79 + }; 80 + dialogTags = [...dialogEntity.tags!]; 81 + dialogUrlValid = URL.canParse(dialogEntity.url); 82 + openDialog(dialogRef); 83 + }; 84 + 85 + const onTagRemove = (ev: Event) => { 86 + if (ev instanceof KeyboardEvent) { 87 + if (["Backspace", "Delete", "Enter"].includes(ev.key) === false) { 88 + return; 89 + } 90 + ev.preventDefault(); 91 + } 92 + const input = ev.target as HTMLInputElement; 93 + const value = input.value; 94 + dialogTags.splice(dialogTags.indexOf(value), 1); 95 + dialogTagValue = value; 96 + tick().then(() => { 97 + if (dialogTagInput) { 98 + dialogTagInput.focus(); 99 + dialogTagInput.select(); 100 + } 101 + }); 102 + }; 103 + 104 + const onTag = (ev: Event) => { 105 + if (dialogTagInput === null) { 106 + return; 107 + } 108 + if (ev instanceof KeyboardEvent) { 109 + if (ev.key === "Enter") { 110 + ev.preventDefault(); 111 + dialogTagInput.blur(); 112 + } 113 + return; 114 + } 115 + dialogTagValue = dialogTagValue.trim(); 116 + const value = dialogTagValue.trim(); 117 + dialogTagValue = ""; 118 + if (value.length) { 119 + if (dialogTags.includes(value) === false) { 120 + dialogTags.push(value); 121 + } 122 + if (dialogTags.length < 8) { 123 + dialogTagInput.focus(); 124 + } 125 + } 126 + }; 56 127 57 128 const dateFormat = $derived( 58 129 new Intl.DateTimeFormat(data.locale, { ··· 61 132 }), 62 133 ); 63 134 64 - const onInputURL: EventHandler<Event, HTMLInputElement> = (ev) => { 65 - const dialog = ev.currentTarget.closest("dialog"); 66 - if (dialog !== createDialog) return; 67 - createURL = URL.parse(ev.currentTarget.value); 68 - }; 69 - 70 135 const onFetchTitle = async (ev: MouseEvent) => { 71 - if (createURL === null) return; 72 - if (createDialog === null) return; 73 - const titleInput = 74 - createDialog.querySelector<HTMLInputElement>('[name="title"]'); 75 - if (titleInput === null) return; 136 + if (dialogTitleInput === null) return; 137 + if (dialogUrlInput === null) return; 138 + if (dialogRef === null) return; 76 139 const formData = new FormData(); 77 - formData.set("url", createURL.href); 140 + formData.set("url", dialogUrlInput.value); 78 141 const button = ev.target as HTMLButtonElement; 79 142 button.disabled = true; 80 143 try { ··· 85 148 const { title } = await response.json(); 86 149 const template = document.createElement("template"); 87 150 template.innerHTML = title; 88 - titleInput.value = template.content.textContent; 151 + dialogTitleInput.value = template.content.textContent; 89 152 } catch { 90 153 // Whatever... 91 154 } finally { ··· 93 156 } 94 157 }; 95 158 96 - $effect(() => { 97 - if (form?.action === "editBookmark" && "error" in form) { 98 - if (editDialog?.open === false) { 99 - openDialog(editDialog); 159 + const onSubmit: SubmitFunction = ({ formData }) => { 160 + return async ({ result, update }) => { 161 + if (result.type !== "success") { 162 + await update(); 163 + return; 100 164 } 101 - } 102 - }); 103 - 104 - $effect(() => { 105 - if (form?.action === "createBookmark" && "error" in form) { 106 - if (createDialog?.open === false) { 107 - openDialog(createDialog); 165 + const rkey = formData.get("rkey"); 166 + const index = rkey 167 + ? bookmarks.findIndex(({ uri }) => uri.endsWith(String(rkey))) 168 + : -1; 169 + if (result.data?.action === "deleteBookmark") { 170 + if (index > -1) { 171 + bookmarks = bookmarks.toSpliced(index, 1); 172 + } else { 173 + await update(); 174 + } 175 + } else if (result.data?.action === "editBookmark") { 176 + if (index > -1 && "record" in result.data) { 177 + bookmarks = bookmarks.toSpliced(index, 1, { 178 + ...bookmarks[index], 179 + ...result.data.record, 180 + // Ensure empty array is undefined 181 + tags: result.data.record.tags, 182 + }); 183 + } else { 184 + await update(); 185 + } 186 + } else if (result.data?.action === "createBookmark") { 187 + if ( 188 + "record" in result.data && 189 + "cid" in result.data && 190 + "uri" in result.data 191 + ) { 192 + bookmarks = bookmarks.toSpliced(0, 0, { 193 + ...result.data.record, 194 + cid: result.data.cid, 195 + uri: result.data.uri, 196 + }); 197 + } else { 198 + await update(); 199 + } 108 200 } 109 - } 110 - }); 201 + await applyAction(result); 202 + closeDialog(dialogRef); 203 + }; 204 + }; 111 205 112 - // [TODO] better error dialog? 113 - $effect(() => { 206 + onMount(() => { 207 + if ( 208 + form?.error && 209 + form?.action && 210 + ["createBookmark", "editBookmark"].includes(form.action) 211 + ) { 212 + setupDialog(); 213 + } 114 214 if (form?.action === "deleteBookmark") { 115 215 if (form.error) alert(form.error); 116 216 } ··· 133 233 134 234 <h1>{data.profile.displayName}</h1> 135 235 136 - {#snippet closeButton(dialog?: HTMLDialogElement)} 137 - <button 138 - type="button" 139 - command="close" 140 - commandfor={dialog?.id} 141 - onclick={(ev) => { 142 - ev.preventDefault(); 143 - closeDialog(dialog); 144 - }} 145 - > 146 - <span class="visually-hidden">close</span> 147 - </button> 148 - {/snippet} 149 - 150 - {#snippet urlInput(value = "")} 151 - <label for="url">URL</label> 152 - <input 153 - type="url" 154 - id="url" 155 - name="url" 156 - maxlength="1280" 157 - placeholder="https://..." 158 - {value} 159 - oninput={onInputURL} 160 - required 161 - /> 162 - {/snippet} 163 - 164 - {#snippet titleInput(value = "")} 165 - <label for="title">Title</label> 166 - <input 167 - type="text" 168 - id="title" 169 - name="title" 170 - maxlength="1280" 171 - {value} 172 - required 173 - /> 174 - {/snippet} 175 - 176 236 {#if isSelf} 177 - <dialog id="create" bind:this={createDialog}> 178 - {@render closeButton(createDialog)} 179 - <form method="POST" action="?/createBookmark"> 180 - <h2>New bookmark</h2> 181 - <p>Please remember: all atproto data is public.</p> 182 - {#if form?.action === "createBookmark" && form?.error} 237 + {@const create = dialogAction === "createBookmark"} 238 + <dialog id="create" bind:this={dialogRef}> 239 + <form 240 + method="POST" 241 + action="?/{dialogAction}" 242 + bind:this={dialogFormRef} 243 + use:enhance={onSubmit} 244 + > 245 + <button 246 + type="button" 247 + command="close" 248 + commandfor="create" 249 + onclick={(ev) => { 250 + ev.preventDefault(); 251 + closeDialog(dialogRef); 252 + }} 253 + > 254 + <span class="visually-hidden">close</span> 255 + </button> 256 + <h2>{create ? "New" : "Edit"} bookmark</h2> 257 + {#if create} 258 + <p>Please remember: all atproto data is public.</p> 259 + {:else} 260 + <input 261 + type="hidden" 262 + name="rkey" 263 + value={form?.rkey ?? dialogEntity?.uri.split("/").at(-1)} 264 + /> 265 + {/if} 266 + {#if form?.error} 183 267 <p class="error">{form.error}</p> 184 268 {/if} 185 - {@render urlInput( 186 - form?.action === "createBookmark" ? form?.data?.url.toString() : "", 187 - )} 269 + <label for="url">URL</label> 270 + <input 271 + type="url" 272 + id="url" 273 + name="url" 274 + maxlength="1024" 275 + placeholder="https://..." 276 + bind:this={dialogUrlInput} 277 + oninput={() => 278 + (dialogUrlValid = dialogUrlInput?.checkValidity() ?? false)} 279 + value={dialogEntity?.url} 280 + required 281 + /> 188 282 <div> 189 - {@render titleInput( 190 - form?.action === "createBookmark" ? form?.data?.title.toString() : "", 191 - )} 283 + <label for="title">Title</label> 284 + <input 285 + type="text" 286 + id="title" 287 + name="title" 288 + maxlength="256" 289 + bind:this={dialogTitleInput} 290 + value={dialogEntity?.title} 291 + required 292 + /> 192 293 <button 193 294 type="button" 194 295 onclick={onFetchTitle} 195 - disabled={createURL === null}>Fetch</button 296 + disabled={dialogUrlValid === false} 196 297 > 298 + Fetch 299 + </button> 197 300 </div> 198 - <button type="submit">Create</button> 199 - </form> 200 - </dialog> 201 - <dialog id="edit" bind:this={editDialog}> 202 - {@render closeButton(editDialog)} 203 - <form method="POST" action="?/editBookmark"> 204 - <input 205 - type="hidden" 206 - name="rkey" 207 - value={form?.action === "editBookmark" 208 - ? form?.data?.rkey.toString() 209 - : editData?.uri.split("/").at(-1)} 210 - /> 211 - <h2>Edit bookmark</h2> 212 - {#if form?.action === "editBookmark" && form?.error} 213 - <p class="error">{form.error}</p> 214 - {/if} 215 - {@render urlInput( 216 - form?.action === "editBookmark" 217 - ? form?.data?.url.toString() 218 - : editData?.url, 219 - )} 220 - {@render titleInput( 221 - form?.action === "editBookmark" 222 - ? form?.data?.title.toString() 223 - : editData?.title, 224 - )} 301 + <label for="tags">Tags <span>(8 max)</span></label> 302 + <div class="Tags"> 303 + {#each dialogTags as tag} 304 + <input 305 + name="tags" 306 + value={tag} 307 + onkeydown={onTagRemove} 308 + onclick={onTagRemove} 309 + readonly 310 + /> 311 + {/each} 312 + {#if dialogTags.length < 8} 313 + <input 314 + id="tags" 315 + name="tags" 316 + maxlength="32" 317 + onblur={onTag} 318 + onkeydown={onTag} 319 + placeholder="tag..." 320 + bind:this={dialogTagInput} 321 + bind:value={dialogTagValue} 322 + /> 323 + {/if} 324 + </div> 225 325 <div class="flex flex-wrap ai-center jc-between"> 226 - <button type="submit">Save</button> 227 - <button 228 - data-danger 229 - type="submit" 230 - formaction="?/deleteBookmark" 231 - onclick={(ev) => { 232 - if (confirm("Are you sure?")) return; 233 - else ev.preventDefault(); 234 - }}>Delete</button 235 - > 326 + <button type="submit">{create ? "Create" : "Save"}</button> 327 + {#if create == false} 328 + <button 329 + data-danger 330 + type="submit" 331 + formaction="?/deleteBookmark" 332 + onclick={(ev) => { 333 + if (confirm("Are you sure?") === false) { 334 + ev.preventDefault(); 335 + } 336 + }}>Delete</button 337 + > 338 + {/if} 236 339 </div> 237 340 </form> 238 341 </dialog> ··· 243 346 <h2>Bookmarks</h2> 244 347 <div class="flex flex-wrap"> 245 348 {#if isSelf} 246 - <button type="button" onclick={() => openDialog(createDialog)}> 247 - New 248 - </button> 349 + <button type="button" onclick={() => setupDialog(undefined, true)} 350 + >New</button 351 + > 249 352 {/if} 250 353 <a href={rssURL.href} class="Button Button-rss" target="_blank"> 251 354 <span class="visually-hidden">RSS</span> ··· 267 370 /> 268 371 {entry.title} 269 372 </a> 373 + {#if isSelf} 374 + <button type="button" onclick={() => setupDialog(entry, true)}> 375 + Edit 376 + </button> 377 + {/if} 270 378 </h3> 271 379 <time datetime={entry.createdAt}> 272 380 {dateFormat.format(new Date(entry.createdAt))} 273 381 </time> 274 382 <code aria-hidden="true">{entry.url}</code> 275 - {#if isSelf} 276 - <div class="flex flex-wrap"> 277 - <button 278 - type="button" 279 - onclick={(ev) => { 280 - ev.preventDefault(); 281 - form = null; 282 - editData = entry; 283 - openDialog(editDialog); 284 - }} 285 - > 286 - Edit 287 - </button> 288 - </div> 383 + {#if entry.tags?.length} 384 + <ul> 385 + {#each entry.tags as tag} 386 + <li>{tag}</li> 387 + {/each} 388 + </ul> 289 389 {/if} 290 390 </article> 291 391 {/each}
+1 -1
src/routes/bookmarks/[did=did]/+page.ts
··· 12 12 13 13 const RECORD_LIMIT = dev ? 5 : 50; 14 14 15 - export const load: PageLoad = async ({ params, data, fetch }) => { 15 + export const load: PageLoad = async ({ data, fetch, params }) => { 16 16 const pds = await resolvePDS(params.did); 17 17 if (pds === null) { 18 18 error(404);
+1 -1
src/routes/bookmarks/[did=did]/rss/+server.ts
··· 38 38 }</content> 39 39 </entry>`); 40 40 41 - export const GET: RequestHandler = async ({ params, url }) => { 41 + export const GET: RequestHandler = async ({ fetch, params, url }) => { 42 42 const pds = await resolvePDS(params.did); 43 43 if (pds === null) { 44 44 error(404);