Browse Source
Merge branch 'main' into 'JSrollBar'
Merge branch 'main' into 'JSrollBar'
# Conflicts: # src/main/java/ChatClient.javaremotes/origin/server
fdai7579
11 months ago
13 changed files with 625 additions and 96 deletions
-
81.classpath
-
45pom.xml
-
26src/main/java/ChatClient.java
-
144src/main/java/ChatGUI.java
-
11src/main/java/ChatServer.java
-
14src/main/java/ClientHandler.java
-
5src/main/java/Constants.java
-
82src/main/java/CreateUser.java
-
162src/main/java/LoginGUI.java
-
39src/main/java/SignUpGUI.java
-
80src/test/java/ChatGUITest.java
-
10src/test/java/TestActionEvent.java
-
22user.json
@ -1,40 +1,41 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<classpath> |
|||
<classpathentry kind="src" output="target/classes" path="src/main/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="src" output="target/test-classes" path="src/test/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="output" path="target/classes"/> |
|||
</classpath> |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<classpath> |
|||
<classpathentry kind="src" output="target/classes" path="src/main/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="src" output="target/test-classes" path="src/test/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.jdt.junit.JUNIT_CONTAINER/5"/> |
|||
<classpathentry kind="output" path="target/classes"/> |
|||
</classpath> |
@ -1,16 +1,29 @@ |
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> |
|||
<modelVersion>4.0.0</modelVersion> |
|||
<groupId>org.progmethoden</groupId> |
|||
<artifactId>java-chat</artifactId> |
|||
<version>0.0.1-SNAPSHOT</version> |
|||
|
|||
<dependencies> |
|||
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson --> |
|||
<dependency> |
|||
<groupId>com.google.code.gson</groupId> |
|||
<artifactId>gson</artifactId> |
|||
<version>2.10.1</version> |
|||
</dependency> |
|||
</dependencies> |
|||
|
|||
</project> |
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> |
|||
<modelVersion>4.0.0</modelVersion> |
|||
<groupId>org.progmethoden</groupId> |
|||
<artifactId>java-chat</artifactId> |
|||
<version>0.0.1-SNAPSHOT</version> |
|||
|
|||
<dependencies> |
|||
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson --> |
|||
<dependency> |
|||
<groupId>com.google.code.gson</groupId> |
|||
<artifactId>gson</artifactId> |
|||
<version>2.10.1</version> |
|||
</dependency> |
|||
<dependency> |
|||
<groupId>junit</groupId> |
|||
<artifactId>junit</artifactId> |
|||
<version>4.13.2</version> |
|||
<scope>test</scope> |
|||
</dependency> |
|||
<dependency> |
|||
<groupId>org.junit.jupiter</groupId> |
|||
<artifactId>junit-jupiter</artifactId> |
|||
<version>RELEASE</version> |
|||
<scope>test</scope> |
|||
</dependency> |
|||
|
|||
</dependencies> |
|||
|
|||
</project> |
@ -0,0 +1,144 @@ |
|||
import java.awt.BorderLayout; |
|||
import java.awt.Color; |
|||
import java.awt.Dimension; |
|||
import java.awt.Font; |
|||
import java.awt.Toolkit; |
|||
import java.awt.event.ActionEvent; |
|||
import java.awt.event.ActionListener; |
|||
|
|||
import javax.swing.JButton; |
|||
import javax.swing.JFrame; |
|||
import javax.swing.JMenu; |
|||
import javax.swing.JMenuBar; |
|||
import javax.swing.JMenuItem; |
|||
import javax.swing.JPanel; |
|||
import javax.swing.JScrollPane; |
|||
import javax.swing.JTextArea; |
|||
import javax.swing.JTextField; |
|||
|
|||
public class ChatGUI implements ActionListener { |
|||
|
|||
// Menu items for font sizes |
|||
JMenuItem small = new JMenuItem("small-font"); |
|||
JMenuItem medium = new JMenuItem("medium-font"); |
|||
JMenuItem large = new JMenuItem("large-font"); |
|||
|
|||
// Menu items for font colors |
|||
JMenuItem black = new JMenuItem("black"); |
|||
JMenuItem red = new JMenuItem("red"); |
|||
JMenuItem blue = new JMenuItem("blue"); |
|||
JMenuItem green = new JMenuItem("green"); |
|||
|
|||
JMenuItem exit = new JMenuItem("Exit"); |
|||
|
|||
JTextField inputTextField = new JTextField(); |
|||
JTextArea outputTextArea = new JTextArea(); |
|||
JButton sendButton = new JButton("Senden"); |
|||
|
|||
JFrame gui; |
|||
|
|||
public ChatGUI() { |
|||
gui = new JFrame(); |
|||
|
|||
// Set up the main frame |
|||
gui.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); |
|||
gui.setTitle("java-chat"); |
|||
gui.setLayout(new BorderLayout()); |
|||
|
|||
// Set up the menu bar |
|||
JMenuBar bar = new JMenuBar(); |
|||
gui.setJMenuBar(bar); |
|||
|
|||
// Create menu items and menus |
|||
JMenu options = new JMenu("options"); |
|||
JMenu colors = new JMenu("font-colors"); |
|||
JMenu size = new JMenu("font-size"); |
|||
|
|||
JMenu menu = new JMenu("File"); |
|||
menu.add(exit); |
|||
menu.add(options); |
|||
bar.add(menu); |
|||
options.add(colors); |
|||
options.add(size); |
|||
|
|||
colors.add(black); |
|||
colors.add(red); |
|||
colors.add(blue); |
|||
colors.add(green); |
|||
|
|||
size.add(small); |
|||
size.add(medium); |
|||
size.add(large); |
|||
|
|||
// Register action listeners for menu items |
|||
red.addActionListener(this); |
|||
blue.addActionListener(this); |
|||
black.addActionListener(this); |
|||
green.addActionListener(this); |
|||
small.addActionListener(this); |
|||
medium.addActionListener(this); |
|||
large.addActionListener(this); |
|||
|
|||
exit.addActionListener(this); |
|||
|
|||
bar.add(menu); |
|||
|
|||
// Set up the output text area with scrolling |
|||
JScrollPane outputScrollPane = new JScrollPane(outputTextArea); |
|||
gui.add(outputScrollPane, BorderLayout.CENTER); |
|||
|
|||
// Set up the input panel with text field and send button |
|||
JPanel inputPanel = new JPanel(new BorderLayout()); |
|||
inputPanel.add(inputTextField, BorderLayout.CENTER); |
|||
inputPanel.add(sendButton, BorderLayout.EAST); |
|||
gui.add(inputPanel, BorderLayout.SOUTH); |
|||
|
|||
inputTextField.addActionListener(this); |
|||
sendButton.addActionListener(this); |
|||
|
|||
// Set the size of the GUI to be a quarter of the screen size |
|||
Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize(); |
|||
int quarterWidth = screenSize.width / 2; |
|||
int quarterHeight = screenSize.height / 2; |
|||
gui.setSize(quarterWidth, quarterHeight); |
|||
gui.setVisible(true); |
|||
} |
|||
|
|||
// main methode zum Testen, müsste bei Implementation entfernt werden |
|||
public static void main(String[] args) { |
|||
|
|||
new ChatGUI(); |
|||
} |
|||
|
|||
@Override |
|||
public void actionPerformed(ActionEvent e) { |
|||
if (e.getSource() == exit) { |
|||
System.exit(0); |
|||
} |
|||
if (e.getSource() == inputTextField || e.getSource() == sendButton) { |
|||
String inputText = inputTextField.getText(); |
|||
outputTextArea.append(inputText + "\n"); |
|||
inputTextField.setText(""); |
|||
} |
|||
if (e.getSource() == red) { |
|||
outputTextArea.setForeground(Color.RED); |
|||
|
|||
} else if (e.getSource() == blue) { |
|||
outputTextArea.setForeground(Color.BLUE); |
|||
} else if (e.getSource() == black) { |
|||
outputTextArea.setForeground(Color.BLACK); |
|||
} else if (e.getSource() == green) { |
|||
outputTextArea.setForeground(Color.GREEN); |
|||
} |
|||
if (e.getSource() == small) { |
|||
outputTextArea.setFont(outputTextArea.getFont().deriveFont(Font.PLAIN, 12)); |
|||
} |
|||
if (e.getSource() == medium) { |
|||
outputTextArea.setFont(outputTextArea.getFont().deriveFont(Font.PLAIN, 16)); |
|||
} |
|||
if (e.getSource() == large) { |
|||
outputTextArea.setFont(outputTextArea.getFont().deriveFont(Font.PLAIN, 20)); |
|||
} |
|||
|
|||
} |
|||
} |
@ -0,0 +1,5 @@ |
|||
public class Constants { |
|||
public static final int WINDOW_WIDTH = 800; |
|||
public static final int WINDOW_HEIGHT = 600; |
|||
public static final int PORT = 3141; |
|||
} |
@ -0,0 +1,162 @@ |
|||
import javax.swing.*; |
|||
import java.awt.event.ActionEvent; |
|||
import java.awt.event.ActionListener; |
|||
import java.util.List; |
|||
import java.awt.event.KeyEvent; |
|||
import java.awt.event.KeyListener; |
|||
|
|||
import java.security.MessageDigest; |
|||
import java.security.NoSuchAlgorithmException; |
|||
|
|||
public class LoginGUI extends JFrame implements ActionListener { |
|||
private JTextField usernameField; |
|||
private JPasswordField passwordField; |
|||
private JButton loginButton; |
|||
private JCheckBox stayLoggedInCheckbox; |
|||
private JButton signUpButton; |
|||
|
|||
public LoginGUI() { |
|||
setTitle("Login"); |
|||
setSize(300, 220); |
|||
setDefaultCloseOperation(EXIT_ON_CLOSE); |
|||
setLayout(null); |
|||
|
|||
JLabel usernameLabel = new JLabel("Username:"); |
|||
usernameLabel.setBounds(20, 20, 80, 25); |
|||
add(usernameLabel); |
|||
|
|||
usernameField = new JTextField(); |
|||
usernameField.setBounds(100, 20, 160, 25); |
|||
add(usernameField); |
|||
|
|||
JLabel passwordLabel = new JLabel("Password:"); |
|||
passwordLabel.setBounds(20, 50, 80, 25); |
|||
add(passwordLabel); |
|||
|
|||
passwordField = new JPasswordField(); |
|||
passwordField.setBounds(100, 50, 160, 25); |
|||
add(passwordField); |
|||
|
|||
stayLoggedInCheckbox = new JCheckBox("Stay Logged In"); |
|||
stayLoggedInCheckbox.setBounds(20, 80, 150, 25); |
|||
add(stayLoggedInCheckbox); |
|||
|
|||
loginButton = new JButton("Login"); |
|||
loginButton.setBounds(100, 110, 100, 25); |
|||
loginButton.addActionListener(this); |
|||
add(loginButton); |
|||
|
|||
signUpButton = new JButton("Sign Up"); |
|||
signUpButton.setBounds(100, 140, 100, 25); // Adjusted position |
|||
signUpButton.addActionListener(this); |
|||
add(signUpButton); |
|||
|
|||
getRootPane().setDefaultButton(loginButton); |
|||
|
|||
passwordField.addKeyListener(new EnterKeyListener()); |
|||
|
|||
stayLoggedInCheckbox.addActionListener(new ActionListener() { |
|||
@Override |
|||
public void actionPerformed(ActionEvent e) { |
|||
boolean stayLoggedIn = stayLoggedInCheckbox.isSelected(); |
|||
String username = usernameField.getText(); |
|||
// Set stayLoggedIn to false if the checkbox is unchecked |
|||
if (!stayLoggedInCheckbox.isSelected()) { |
|||
stayLoggedIn = false; |
|||
} |
|||
updateStayLoggedIn(username, stayLoggedIn); |
|||
} |
|||
}); |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public void actionPerformed(ActionEvent e) { |
|||
if (e.getSource() == loginButton) { |
|||
login(); |
|||
}else if (e.getSource() == signUpButton) { |
|||
SignUpGUI signUpGUI = new SignUpGUI(); |
|||
signUpGUI.setVisible(true); |
|||
|
|||
} |
|||
} |
|||
|
|||
private void login() { |
|||
String username = usernameField.getText(); |
|||
String password = new String(passwordField.getPassword()); |
|||
boolean stayLoggedIn = stayLoggedInCheckbox.isSelected(); // Get checkbox state |
|||
|
|||
if (authenticateUser(username, password)) { |
|||
JOptionPane.showMessageDialog(this, "Login successful!"); |
|||
// Perform actions after successful login |
|||
|
|||
dispose(); |
|||
} else { |
|||
JOptionPane.showMessageDialog(this, "Invalid username or password", "Login Error", JOptionPane.ERROR_MESSAGE); |
|||
} |
|||
} |
|||
|
|||
private void updateStayLoggedIn(String username, boolean stayLoggedIn) { |
|||
// Update stayLoggedIn in the JSON file for the user |
|||
CreateUser.updateStayLoggedIn("user.json", username, stayLoggedIn); |
|||
} |
|||
|
|||
// Function to authenticate the user by comparing the entered username and password with the saved user data |
|||
private boolean authenticateUser(String username, String password) { |
|||
List<CreateUser> userList = CreateUser.readUserListFromJsonFile("user.json"); |
|||
if (userList != null) { |
|||
for (CreateUser user : userList) { |
|||
if (user.getUserName().equals(username)) { |
|||
// Hash the user input password |
|||
String hashedPassword = hashPassword(password); |
|||
// Compare the hashed passwords |
|||
if (user.getPassword().equals(hashedPassword)) { |
|||
return true; // Success |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return false; // Fail |
|||
} |
|||
|
|||
private String hashPassword(String password) { |
|||
try { |
|||
MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
|||
byte[] hash = digest.digest(password.getBytes()); |
|||
StringBuilder hexString = new StringBuilder(); |
|||
for (byte b : hash) { |
|||
String hex = Integer.toHexString(0xff & b); |
|||
if (hex.length() == 1) { |
|||
hexString.append('0'); |
|||
} |
|||
hexString.append(hex); |
|||
} |
|||
return hexString.toString(); |
|||
} catch (NoSuchAlgorithmException e) { |
|||
e.printStackTrace(); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private class EnterKeyListener implements KeyListener { |
|||
@Override |
|||
public void keyTyped(KeyEvent e) {} |
|||
|
|||
@Override |
|||
public void keyPressed(KeyEvent e) { |
|||
if (e.getKeyCode() == KeyEvent.VK_ENTER) { |
|||
login(); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void keyReleased(KeyEvent e) {} |
|||
} |
|||
|
|||
public static void main(String[] args) { |
|||
SwingUtilities.invokeLater(() -> { |
|||
LoginGUI loginGUI = new LoginGUI(); |
|||
loginGUI.setVisible(true); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,80 @@ |
|||
import static org.junit.Assert.assertEquals; |
|||
|
|||
import java.awt.Color; |
|||
import java.awt.Font; |
|||
import java.awt.event.ActionEvent; |
|||
|
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
|
|||
public class ChatGUITest { |
|||
|
|||
private ChatGUI chatGUI; |
|||
|
|||
@Before |
|||
public void setUp() { |
|||
chatGUI = new ChatGUI(); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextColorRed() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.red)); |
|||
assertEquals(Color.RED, chatGUI.outputTextArea.getForeground()); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextColorBlue() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.blue)); |
|||
assertEquals(Color.BLUE, chatGUI.outputTextArea.getForeground()); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextColorBlack() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.black)); |
|||
assertEquals(Color.BLACK, chatGUI.outputTextArea.getForeground()); |
|||
} |
|||
|
|||
@Test |
|||
public void testSetOutputTextColorGreen() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.green)); |
|||
assertEquals(Color.GREEN, chatGUI.outputTextArea.getForeground()); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextFontSizeSmall() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.small)); |
|||
Font expectedFont = chatGUI.outputTextArea.getFont().deriveFont(Font.PLAIN, 12); |
|||
assertEquals(expectedFont, chatGUI.outputTextArea.getFont()); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextFontSizeMedium() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.medium)); |
|||
Font expectedFont = chatGUI.outputTextArea.getFont().deriveFont(Font.PLAIN, 16); |
|||
assertEquals(expectedFont, chatGUI.outputTextArea.getFont()); |
|||
} |
|||
@Test |
|||
public void testSetOutputTextFontSizeLarge() { |
|||
chatGUI.actionPerformed(new TestActionEvent(chatGUI.large)); |
|||
Font expectedFont = chatGUI.outputTextArea.getFont().deriveFont(Font.PLAIN, 20); |
|||
assertEquals(expectedFont, chatGUI.outputTextArea.getFont()); |
|||
} |
|||
@Test |
|||
public void testSendButtonActionPerformed() { |
|||
chatGUI.inputTextField.setText("Testnachricht"); |
|||
chatGUI.sendButton.doClick(); |
|||
|
|||
String expectedOutput = "Testnachricht\n"; |
|||
assertEquals(expectedOutput, chatGUI.outputTextArea.getText()); |
|||
} |
|||
@Test |
|||
public void testInputTextFieldActionPerformed() { |
|||
|
|||
chatGUI.inputTextField.setText("Testnachricht"); |
|||
chatGUI.actionPerformed(new ActionEvent(chatGUI.inputTextField, ActionEvent.ACTION_PERFORMED, "")); |
|||
|
|||
|
|||
String expectedOutput = "Testnachricht\n"; |
|||
assertEquals(expectedOutput, chatGUI.outputTextArea.getText()); |
|||
|
|||
|
|||
assertEquals("", chatGUI.inputTextField.getText()); |
|||
} |
|||
} |
|||
|
|||
|
@ -0,0 +1,10 @@ |
|||
import java.awt.event.ActionEvent; |
|||
|
|||
public class TestActionEvent extends ActionEvent { |
|||
|
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
public TestActionEvent(Object source) { |
|||
super(source, ActionEvent.ACTION_PERFORMED, "Test command"); |
|||
} |
|||
} |
@ -1,14 +1,20 @@ |
|||
[ |
|||
{ |
|||
"id": "961ca202-ecbd-4dfc-ac0b-28f367618aa1", |
|||
"userName": "asd", |
|||
"password": "123456", |
|||
"birthday": "1" |
|||
"id": "d7ae19fe-4684-4d69-a73d-4cca612962a3", |
|||
"userName": "Test", |
|||
"password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", |
|||
"birthday": "", |
|||
"firstName": "", |
|||
"surname": "", |
|||
"stayLoggedIn": false |
|||
}, |
|||
{ |
|||
"id": "d563a466-753b-4a5e-8b6c-e7e4756c7397", |
|||
"userName": "asd1", |
|||
"password": "123456", |
|||
"birthday": "1" |
|||
"id": "2ec2c0c5-677c-4262-8958-fef98d11cc63", |
|||
"userName": "Test2", |
|||
"password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", |
|||
"birthday": "", |
|||
"firstName": "", |
|||
"surname": "", |
|||
"stayLoggedIn": false |
|||
} |
|||
] |
Write
Preview
Loading…
Cancel
Save
Reference in new issue