build
This commit is contained in:
parent
b8d3fe6f1a
commit
f97837d15a
10 changed files with 526 additions and 295 deletions
67
docs/dist/App.js
vendored
67
docs/dist/App.js
vendored
|
|
@ -10,7 +10,7 @@ import hot from "../hot.js";
|
|||
import {isNote} from "../_snowpack/pkg/tone.js";
|
||||
import {useWebMidi} from "./midi.js";
|
||||
const [_, codeParam] = window.location.href.split("#");
|
||||
const decoded = atob(codeParam || "");
|
||||
const decoded = atob(decodeURIComponent(codeParam || ""));
|
||||
const getHotCode = async () => {
|
||||
return fetch("/hot.js").then((res) => res.text()).then((src) => {
|
||||
return src.split("export default").slice(-1)[0].trim();
|
||||
|
|
@ -30,17 +30,37 @@ function getRandomTune() {
|
|||
}
|
||||
const randomTune = getRandomTune();
|
||||
function App() {
|
||||
const [mode, setMode] = useState("javascript");
|
||||
const [code, setCode] = useState(decoded || randomTune);
|
||||
const [activeCode, setActiveCode] = useState();
|
||||
const [log, setLog] = useState("");
|
||||
const logBox = useRef();
|
||||
const [error, setError] = useState();
|
||||
const [pattern, setPattern] = useState();
|
||||
const [activePattern, setActivePattern] = useState();
|
||||
const activatePattern = (_pattern = pattern) => {
|
||||
setActivePattern(() => _pattern);
|
||||
window.location.hash = "#" + btoa(code);
|
||||
!cycle.started && cycle.start();
|
||||
const dirty = code !== activeCode;
|
||||
const activateCode = (_code = code) => {
|
||||
if (activeCode && !dirty) {
|
||||
setError(void 0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = evaluate(_code);
|
||||
setPattern(() => parsed.pattern);
|
||||
activatePattern(parsed.pattern);
|
||||
setError(void 0);
|
||||
setActiveCode(_code);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
const activatePattern = (_pattern) => {
|
||||
try {
|
||||
setActivePattern(() => _pattern);
|
||||
window.location.hash = "#" + encodeURIComponent(btoa(code));
|
||||
!cycle.started && cycle.start();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
const [isHot, setIsHot] = useState(false);
|
||||
const pushLog = (message) => setLog((log2) => log2 + `${log2 ? "\n\n" : ""}${message}`);
|
||||
|
|
@ -85,7 +105,7 @@ function App() {
|
|||
if (e.ctrlKey || e.altKey) {
|
||||
switch (e.code) {
|
||||
case "Enter":
|
||||
activatePattern();
|
||||
activateCode();
|
||||
break;
|
||||
case "Period":
|
||||
cycle.stop();
|
||||
|
|
@ -94,34 +114,19 @@ function App() {
|
|||
};
|
||||
document.addEventListener("keypress", handleKeyPress);
|
||||
return () => document.removeEventListener("keypress", handleKeyPress);
|
||||
}, [pattern]);
|
||||
}, [pattern, code]);
|
||||
useEffect(() => {
|
||||
let _code = code;
|
||||
if (isHot) {
|
||||
if (typeof hot !== "string") {
|
||||
getHotCode().then((_code2) => {
|
||||
setCode(_code2);
|
||||
setMode("javascript");
|
||||
getHotCode().then((_code) => {
|
||||
});
|
||||
activatePattern(hot);
|
||||
return;
|
||||
} else {
|
||||
_code = hot;
|
||||
setCode(_code);
|
||||
setCode(hot);
|
||||
activateCode(hot);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const parsed = evaluate(_code);
|
||||
setPattern(() => parsed.pattern);
|
||||
if (isHot) {
|
||||
activatePattern(parsed.pattern);
|
||||
}
|
||||
setMode(parsed.mode);
|
||||
setError(void 0);
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
setError(err);
|
||||
}
|
||||
}, [code, isHot]);
|
||||
useLayoutEffect(() => {
|
||||
logBox.current.scrollTop = logBox.current?.scrollHeight;
|
||||
|
|
@ -175,7 +180,7 @@ function App() {
|
|||
value: code,
|
||||
readOnly: isHot,
|
||||
options: {
|
||||
mode,
|
||||
mode: "javascript",
|
||||
theme: "material",
|
||||
lineNumbers: true
|
||||
},
|
||||
|
|
@ -187,14 +192,14 @@ function App() {
|
|||
}), /* @__PURE__ */ React.createElement("span", {
|
||||
className: "p-4 absolute top-0 right-0 text-xs whitespace-pre text-right"
|
||||
}, !cycle.started ? `press ctrl+enter to play
|
||||
` : !isHot && activePattern !== pattern ? `ctrl+enter to update
|
||||
` : "no changes\n", !isHot && /* @__PURE__ */ React.createElement(React.Fragment, null, {pegjs: "mini"}[mode] || mode, " mode"), isHot && "🔥 hot mode: go to hot.js to edit pattern, then save")), error && /* @__PURE__ */ React.createElement("div", {
|
||||
className: "absolute right-2 bottom-2 text-red-500"
|
||||
` : !isHot && code !== activeCode ? `ctrl+enter to update
|
||||
` : "no changes\n", isHot && "🔥 hot mode: go to hot.js to edit pattern, then save")), error && /* @__PURE__ */ React.createElement("div", {
|
||||
className: cx("absolute right-2 bottom-2", "text-red-500")
|
||||
}, error?.message || "unknown error")), /* @__PURE__ */ React.createElement("button", {
|
||||
className: "flex-none w-full border border-gray-700 p-2 bg-slate-700 hover:bg-slate-500",
|
||||
onClick: () => {
|
||||
if (!cycle.started) {
|
||||
activatePattern();
|
||||
activateCode();
|
||||
} else {
|
||||
cycle.stop();
|
||||
}
|
||||
|
|
|
|||
10
docs/dist/evaluate.js
vendored
10
docs/dist/evaluate.js
vendored
|
|
@ -6,6 +6,8 @@ import "./tonal.js";
|
|||
import "./groove.js";
|
||||
import shapeshifter from "./shapeshifter.js";
|
||||
import {minify} from "./parse.js";
|
||||
import * as Tone from "../_snowpack/pkg/tone.js";
|
||||
import * as toneHelpers from "./tone.js";
|
||||
const bootstrapped = {...strudel, ...strudel.Pattern.prototype.bootstrap()};
|
||||
function hackLiteral(literal, names, func) {
|
||||
names.forEach((name) => {
|
||||
|
|
@ -18,10 +20,14 @@ function hackLiteral(literal, names, func) {
|
|||
}
|
||||
hackLiteral(String, ["mini", "m"], bootstrapped.mini);
|
||||
hackLiteral(String, ["pure", "p"], bootstrapped.pure);
|
||||
Object.assign(globalThis, bootstrapped);
|
||||
Object.assign(globalThis, bootstrapped, Tone, toneHelpers);
|
||||
export const evaluate = (code) => {
|
||||
const shapeshifted = shapeshifter(code);
|
||||
const pattern = minify(eval(shapeshifted));
|
||||
let evaluated = eval(shapeshifted);
|
||||
if (typeof evaluated === "function") {
|
||||
evaluated = evaluated();
|
||||
}
|
||||
const pattern = minify(evaluated);
|
||||
if (pattern?.constructor?.name !== "Pattern") {
|
||||
const message = `got "${typeof pattern}" instead of pattern`;
|
||||
throw new Error(message + (typeof pattern === "function" ? ", did you forget to call a function?" : "."));
|
||||
|
|
|
|||
34
docs/dist/parse.js
vendored
34
docs/dist/parse.js
vendored
|
|
@ -23,9 +23,40 @@ const applyOptions = (parent) => (pat, i) => {
|
|||
}
|
||||
return pat;
|
||||
};
|
||||
function resolveReplications(ast) {
|
||||
ast.source_ = ast.source_.map((child) => {
|
||||
const {replicate, ...options} = child.options_ || {};
|
||||
if (replicate) {
|
||||
return {
|
||||
...child,
|
||||
options_: {...options, weight: replicate},
|
||||
source_: {
|
||||
type_: "pattern",
|
||||
arguments_: {
|
||||
alignment: "h"
|
||||
},
|
||||
source_: [
|
||||
{
|
||||
type_: "element",
|
||||
source_: child.source_,
|
||||
options_: {
|
||||
operator: {
|
||||
type_: "stretch",
|
||||
arguments_: {amount: String(new Fraction(replicate).inverse().valueOf())}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
export function patternifyAST(ast) {
|
||||
switch (ast.type_) {
|
||||
case "pattern":
|
||||
resolveReplications(ast);
|
||||
const children = ast.source_.map(patternifyAST).map(applyOptions(ast));
|
||||
const alignment = ast.arguments_.alignment;
|
||||
if (alignment === "v") {
|
||||
|
|
@ -38,7 +69,8 @@ export function patternifyAST(ast) {
|
|||
if (weightedChildren) {
|
||||
const pat = timeCat(...ast.source_.map((child, i) => [child.options_?.weight || 1, children[i]]));
|
||||
if (alignment === "t") {
|
||||
return pat._slow(children.length);
|
||||
const weightSum = ast.source_.reduce((sum, child) => sum + (child.options_?.weight || 1), 0);
|
||||
return pat._slow(weightSum);
|
||||
}
|
||||
return pat;
|
||||
}
|
||||
|
|
|
|||
2
docs/dist/tonal.js
vendored
2
docs/dist/tonal.js
vendored
|
|
@ -46,7 +46,7 @@ function scaleTranspose(scale, offset, note) {
|
|||
Pattern.prototype._mapNotes = function(func) {
|
||||
return this.fmap((event) => {
|
||||
const noteEvent = toNoteEvent(event);
|
||||
return func(noteEvent);
|
||||
return {...noteEvent, ...func(noteEvent)};
|
||||
});
|
||||
};
|
||||
Pattern.prototype._transpose = function(intervalOrSemitones) {
|
||||
|
|
|
|||
59
docs/dist/tone.js
vendored
59
docs/dist/tone.js
vendored
|
|
@ -1,6 +1,62 @@
|
|||
import {Pattern as _Pattern} from "../_snowpack/link/strudel.js";
|
||||
import {AutoFilter, Destination, Filter, Gain, isNote, Synth} from "../_snowpack/pkg/tone.js";
|
||||
import {AutoFilter, Destination, Filter, Gain, isNote, Synth, PolySynth} from "../_snowpack/pkg/tone.js";
|
||||
const Pattern = _Pattern;
|
||||
Pattern.prototype.tone = function(instrument) {
|
||||
return this.fmap((value) => {
|
||||
value = typeof value !== "object" && !Array.isArray(value) ? {value} : value;
|
||||
const onTrigger = (time, event) => {
|
||||
if (instrument.constructor.name === "PluckSynth") {
|
||||
instrument.triggerAttack(value.value, time);
|
||||
} else if (instrument.constructor.name === "NoiseSynth") {
|
||||
instrument.triggerAttackRelease(event.duration, time);
|
||||
} else {
|
||||
instrument.triggerAttackRelease(value.value, event.duration, time);
|
||||
}
|
||||
};
|
||||
return {...value, instrument, onTrigger};
|
||||
});
|
||||
};
|
||||
Pattern.prototype.define("tone", (type, pat) => pat.tone(type), {composable: true, patternified: false});
|
||||
export const vol = (v) => new Gain(v);
|
||||
export const lowpass = (v) => new Filter(v, "lowpass");
|
||||
export const highpass = (v) => new Filter(v, "highpass");
|
||||
export const adsr = (a, d = 0.1, s = 0.4, r = 0.01) => ({envelope: {attack: a, decay: d, sustain: s, release: r}});
|
||||
export const osc = (type) => ({oscillator: {type}});
|
||||
export const out = Destination;
|
||||
const chainable = function(instr) {
|
||||
const _chain = instr.chain.bind(instr);
|
||||
let chained = [];
|
||||
instr.chain = (...args) => {
|
||||
chained = chained.concat(args);
|
||||
instr.disconnect();
|
||||
return _chain(...chained, Destination);
|
||||
};
|
||||
instr.filter = (freq = 1e3, type = "lowpass") => instr.chain(new Filter(freq, type));
|
||||
instr.gain = (gain2 = 0.9) => instr.chain(new Gain(gain2));
|
||||
return instr;
|
||||
};
|
||||
export const poly = (type) => {
|
||||
const s = new PolySynth(Synth, {oscillator: {type}}).toDestination();
|
||||
return chainable(s);
|
||||
};
|
||||
Pattern.prototype._poly = function(type = "triangle") {
|
||||
const instrumentConfig = {
|
||||
oscillator: {type},
|
||||
envelope: {attack: 0.01, decay: 0.01, sustain: 0.6, release: 0.01}
|
||||
};
|
||||
if (!this.instrument) {
|
||||
this.instrument = poly(type);
|
||||
}
|
||||
return this.fmap((value) => {
|
||||
value = typeof value !== "object" && !Array.isArray(value) ? {value} : value;
|
||||
const onTrigger = (time, event) => {
|
||||
this.instrument.set(instrumentConfig);
|
||||
this.instrument.triggerAttackRelease(value.value, event.duration, time);
|
||||
};
|
||||
return {...value, instrumentConfig, onTrigger};
|
||||
});
|
||||
};
|
||||
Pattern.prototype.define("poly", (type, pat) => pat.poly(type), {composable: true, patternified: true});
|
||||
const getTrigger = (getChain, value) => (time, event) => {
|
||||
const chain = getChain();
|
||||
if (!isNote(value)) {
|
||||
|
|
@ -68,7 +124,6 @@ Pattern.prototype._filter = function(freq, q, type = "lowpass") {
|
|||
Pattern.prototype.autofilter = function(g) {
|
||||
return this.chain(autofilter(g));
|
||||
};
|
||||
Pattern.prototype.patternified = Pattern.prototype.patternified.concat(["synth", "gain", "filter"]);
|
||||
Pattern.prototype.define("synth", (type, pat) => pat.synth(type), {composable: true, patternified: true});
|
||||
Pattern.prototype.define("gain", (gain2, pat) => pat.synth(gain2), {composable: true, patternified: true});
|
||||
Pattern.prototype.define("filter", (cutoff, pat) => pat.filter(cutoff), {composable: true, patternified: true});
|
||||
|
|
|
|||
91
docs/dist/tunes.js
vendored
91
docs/dist/tunes.js
vendored
|
|
@ -250,12 +250,11 @@ export const groove = `stack(
|
|||
.voicings(['G3','A4'])
|
||||
).slow(4)`;
|
||||
export const magicSofa = `stack(
|
||||
'[C^7 F^7 ~]/3 [Dm7 G7 A7 ~]/4'.mini
|
||||
'<C^7 F^7 ~> <Dm7 G7 A7 ~>'.m
|
||||
.every(2, fast(2))
|
||||
.voicings(),
|
||||
'[c2 f2 g2]/3 [d2 g2 a2 e2]/4'.mini
|
||||
).slow(1)
|
||||
.transpose.slowcat(0, 2, 3, 4)`;
|
||||
'<c2 f2 g2> <d2 g2 a2 e2>'.m
|
||||
).slow(1).transpose.slowcat(0, 2, 3, 4)`;
|
||||
export const confusedPhoneDynamic = `stack('[g2 ~@1.3] [c3 ~@1.3]'.mini.slow(2))
|
||||
.superimpose(
|
||||
...[-12,7,10,12,24].slice(0,5).map((t,i,{length}) => x => transpose(t,x).late(i/length))
|
||||
|
|
@ -274,3 +273,87 @@ export const confusedPhone = `'[g2 ~@1.3] [c3 ~@1.3]'.mini
|
|||
.scale(slowcat('C dorian', 'C mixolydian'))
|
||||
.scaleTranspose(slowcat(0,1,2,1))
|
||||
.slow(2)`;
|
||||
export const zeldasRescue = `stack(
|
||||
// melody
|
||||
\`[B3@2 D4] [A3@2 [G3 A3]] [B3@2 D4] [A3]
|
||||
[B3@2 D4] [A4@2 G4] [D4@2 [C4 B3]] [A3]
|
||||
[B3@2 D4] [A3@2 [G3 A3]] [B3@2 D4] [A3]
|
||||
[B3@2 D4] [A4@2 G4] D5@2
|
||||
[D5@2 [C5 B4]] [[C5 B4] G4@2] [C5@2 [B4 A4]] [[B4 A4] E4@2]
|
||||
[D5@2 [C5 B4]] [[C5 B4] G4 C5] [G5] [~ ~ B3]\`.mini,
|
||||
// bass
|
||||
\`[[C2 G2] E3@2] [[C2 G2] F#3@2] [[C2 G2] E3@2] [[C2 G2] F#3@2]
|
||||
[[B1 D3] G3@2] [[Bb1 Db3] G3@2] [[A1 C3] G3@2] [[D2 C3] F#3@2]
|
||||
[[C2 G2] E3@2] [[C2 G2] F#3@2] [[C2 G2] E3@2] [[C2 G2] F#3@2]
|
||||
[[B1 D3] G3@2] [[Bb1 Db3] G3@2] [[A1 C3] G3@2] [[D2 C3] F#3@2]
|
||||
[[F2 C3] E3@2] [[E2 B2] D3@2] [[D2 A2] C3@2] [[C2 G2] B2@2]
|
||||
[[F2 C3] E3@2] [[E2 B2] D3@2] [[Eb2 Bb2] Db3@2] [[D2 A2] C3 [F3,G2]]\`.mini
|
||||
).transpose(12).slow(48).tone(
|
||||
new PolySynth().chain(
|
||||
new Gain(0.3),
|
||||
new Chorus(2, 2.5, 0.5).start(),
|
||||
new Freeverb(),
|
||||
Destination)
|
||||
)`;
|
||||
export const technoDrums = `stack(
|
||||
'c1*2'.m.tone(new Tone.MembraneSynth().toDestination()),
|
||||
'~ x'.m.tone(new Tone.NoiseSynth().toDestination()),
|
||||
'[~ c4]*2'.m.tone(new Tone.MetalSynth().set({envelope:{decay:0.06,sustain:0}}).chain(new Gain(0.5),Destination))
|
||||
)`;
|
||||
export const loungerave = `() => {
|
||||
const delay = new FeedbackDelay(1/8, .2).chain(vol(0.5), out);
|
||||
const kick = new MembraneSynth().chain(vol(.8), out);
|
||||
const snare = new NoiseSynth().chain(vol(.8), out);
|
||||
const hihat = new MetalSynth().set(adsr(0, .08, 0, .1)).chain(vol(.3).connect(delay),out);
|
||||
const bass = new Synth().set({ ...osc('sawtooth'), ...adsr(0, .1, .4) }).chain(lowpass(900), vol(.5), out);
|
||||
const keys = new PolySynth().set({ ...osc('sawtooth'), ...adsr(0, .5, .2, .7) }).chain(lowpass(1200), vol(.5), out);
|
||||
|
||||
const drums = stack(
|
||||
'c1*2'.m.tone(kick).bypass('<0@7 1>/8'.m),
|
||||
'~ <x!7 [x@3 x]>'.m.tone(snare).bypass('<0@7 1>/4'.m),
|
||||
'[~ c4]*2'.m.tone(hihat)
|
||||
);
|
||||
|
||||
const thru = (x) => x.transpose('<0 1>/8'.m).transpose(1);
|
||||
const synths = stack(
|
||||
'<C2 Bb1 Ab1 [G1 [G2 G1]]>/2'.m.groove('[x [~ x] <[~ [~ x]]!3 [x x]>@2]/2'.m).edit(thru).tone(bass),
|
||||
'<Cm7 Bb7 Fm7 G7b9>/2'.m.groove('~ [x@0.1 ~]'.m).voicings().edit(thru).every(2, early(1/4)).tone(keys).bypass('<0@7 1>/8'.m.early(1/4))
|
||||
)
|
||||
return stack(
|
||||
drums,
|
||||
synths
|
||||
)
|
||||
//.bypass('<0 1>*4'.m)
|
||||
//.early('0.25 0'.m);
|
||||
}`;
|
||||
export const caverave = `() => {
|
||||
const delay = new FeedbackDelay(1/8, .4).chain(vol(0.5), out);
|
||||
const kick = new MembraneSynth().chain(vol(.8), out);
|
||||
const snare = new NoiseSynth().chain(vol(.8), out);
|
||||
const hihat = new MetalSynth().set(adsr(0, .08, 0, .1)).chain(vol(.3).connect(delay),out);
|
||||
const bass = new Synth().set({ ...osc('sawtooth'), ...adsr(0, .1, .4) }).chain(lowpass(900), vol(.5), out);
|
||||
const keys = new PolySynth().set({ ...osc('sawtooth'), ...adsr(0, .5, .2, .7) }).chain(lowpass(1200), vol(.5), out);
|
||||
|
||||
const drums = stack(
|
||||
'c1*2'.m.tone(kick).bypass('<0@7 1>/8'.m),
|
||||
'~ <x!7 [x@3 x]>'.m.tone(snare).bypass('<0@7 1>/4'.m),
|
||||
'[~ c4]*2'.m.tone(hihat)
|
||||
);
|
||||
|
||||
const thru = (x) => x.transpose('<0 1>/8'.m).transpose(-1);
|
||||
const synths = stack(
|
||||
'<eb4 d4 c4 b3>/2'.m.scale(timeCat([3,'C minor'],[1,'C melodic minor']).slow(8)).groove('[~ x]*2'.m)
|
||||
.edit(
|
||||
scaleTranspose(0).early(0),
|
||||
scaleTranspose(2).early(1/8),
|
||||
scaleTranspose(7).early(1/4),
|
||||
scaleTranspose(8).early(3/8)
|
||||
).edit(thru).tone(keys).bypass('<1 0>/16'.m),
|
||||
'<C2 Bb1 Ab1 [G1 [G2 G1]]>/2'.m.groove('[x [~ x] <[~ [~ x]]!3 [x x]>@2]/2'.m.fast(2)).edit(thru).tone(bass),
|
||||
'<Cm7 Bb7 Fm7 G7b13>/2'.m.groove('~ [x@0.1 ~]'.m.fast(2)).voicings().edit(thru).every(2, early(1/8)).tone(keys).bypass('<0@7 1>/8'.m.early(1/4))
|
||||
)
|
||||
return stack(
|
||||
drums.fast(2),
|
||||
synths
|
||||
).slow(2);
|
||||
}`;
|
||||
|
|
|
|||
12
docs/dist/useCycle.js
vendored
12
docs/dist/useCycle.js
vendored
|
|
@ -8,12 +8,8 @@ function useCycle(props) {
|
|||
const activeCycle = () => Math.floor(Tone.Transport.seconds / cycleDuration);
|
||||
const query = (cycle = activeCycle()) => {
|
||||
const timespan = new TimeSpan(cycle, cycle + 1);
|
||||
const _events = onQuery?.(timespan) || [];
|
||||
onSchedule?.(_events, cycle);
|
||||
schedule(_events, cycle);
|
||||
};
|
||||
const schedule = (events, cycle = activeCycle()) => {
|
||||
const timespan = new TimeSpan(cycle, cycle + 1);
|
||||
const events = onQuery?.(timespan) || [];
|
||||
onSchedule?.(events, cycle);
|
||||
const cancelFrom = timespan.begin.valueOf();
|
||||
Tone.Transport.cancel(cancelFrom);
|
||||
const queryNextTime = (cycle + 1) * cycleDuration - 0.1;
|
||||
|
|
@ -29,7 +25,7 @@ function useCycle(props) {
|
|||
Tone.Transport.schedule((time) => {
|
||||
const toneEvent = {
|
||||
time: event.part.begin.valueOf(),
|
||||
duration: event.whole.end.valueOf() - event.whole.begin.valueOf(),
|
||||
duration: event.whole.end.sub(event.whole.begin).valueOf(),
|
||||
value: event.value
|
||||
};
|
||||
onEvent(time, toneEvent);
|
||||
|
|
@ -51,6 +47,6 @@ function useCycle(props) {
|
|||
Tone.Transport.pause();
|
||||
};
|
||||
const toggle = () => started ? stop() : start();
|
||||
return {start, stop, onEvent, started, toggle, schedule, query, activeCycle};
|
||||
return {start, stop, onEvent, started, toggle, query, activeCycle};
|
||||
}
|
||||
export default useCycle;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue